use std::future::IntoFuture as _;
use super::super::managed_render::{
format_ls_row, format_state_column, format_tombstone_row, short_timestamp, state_column_width,
truncate,
};
use super::{session_activity, session_decommission, session_resume, session_stop};
use crate::test_support::tmux_session::{PrivateTmuxServer, ScratchTmuxSession};
use trusty_mpm::core::tmux::{exact_session_target, with_tmux_binary};
#[test]
fn truncate_clips_and_appends_ellipsis() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hell\u{2026}");
assert_eq!(truncate("", 5), "");
assert_eq!(truncate("abcde", 5), "abcde");
}
#[test]
fn format_state_column_appends_dead_marker() {
assert_eq!(
format_state_column("stopped", true, false, false, false),
"stopped [dead]"
);
assert_eq!(
format_state_column("errored", true, false, false, false),
"errored [dead]"
);
}
#[test]
fn format_state_column_appends_stale_assets_marker() {
assert_eq!(
format_state_column("active", false, true, false, false),
"active [stale-assets]"
);
assert_eq!(
format_state_column("stopped", true, true, false, false),
"stopped [dead] [stale-assets]",
"both markers must be able to appear together"
);
}
#[test]
fn format_state_column_appends_unchecked_assets_marker() {
assert_eq!(
format_state_column("stopped", false, false, true, false),
"stopped [assets ?]"
);
assert_eq!(
format_state_column("stopped", true, false, true, false),
"stopped [dead] [assets ?]",
"the dead marker and the undetermined-assets marker must compose"
);
assert_eq!(
format_state_column("stopped", false, true, true, false),
"stopped [stale-assets]",
"a real stale verdict must win over 'undetermined' — never both"
);
}
#[test]
fn format_state_column_appends_resume_parked_marker() {
assert_eq!(
format_state_column("stopped", false, false, false, true),
"stopped [resume-parked]"
);
assert_eq!(
format_state_column("stopped", true, true, false, true),
"stopped [dead] [resume-parked] [stale-assets]"
);
assert_eq!(
format_state_column("stopped", false, false, false, false),
"stopped"
);
}
#[test]
fn format_state_column_leaves_healthy_state_unchanged() {
assert_eq!(
format_state_column("active", false, false, false, false),
"active"
);
assert_eq!(
format_state_column("stopped", false, false, false, false),
"stopped"
);
assert_eq!(
format_state_column("provisioning", false, false, false, false),
"provisioning"
);
}
#[test]
fn format_tombstone_row_shows_slot_and_placeholder() {
assert_eq!(format_tombstone_row(1, false), "1 -- deleted --");
assert_eq!(format_tombstone_row(42, false), "42 -- deleted --");
}
#[test]
fn format_state_column_renders_deleted_marker() {
assert_eq!(
format_state_column("deleted", false, false, false, false),
"--deleted--"
);
assert_eq!(
format_state_column("deleted", true, false, false, false),
"--deleted-- [dead]"
);
}
#[test]
fn short_timestamp_formats_correctly() {
assert_eq!(short_timestamp("2025-06-27T14:32:00Z"), "2025-06-27 14:32");
assert_eq!(short_timestamp("short"), "short");
assert_eq!(short_timestamp("2025-06-27T14:32"), "2025-06-27 14:32");
}
#[tokio::test]
async fn session_decommission_prints_daemon_verdict_over_http() {
use std::future::IntoFuture as _;
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-unowned-ws"));
std::fs::create_dir_all(&ws).unwrap();
state
.session_manager()
.await
.create_with_id(
id,
"regression: #5899 decommission over HTTP".to_string(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, api::router(state)).into_future());
session_decommission(
&trusty_mpm::client::DaemonClient::with_client(
reqwest::Client::new(),
format!("http://{addr}"),
),
id.to_string(),
)
.await
.expect("decommissioning a seeded session must succeed");
assert!(
ws.exists(),
"an unowned workspace must survive decommission: {}",
ws.display()
);
}
async fn spawn_test_daemon() -> String {
use trusty_mpm::daemon::{api, state::DaemonState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root).await);
let router = api::router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
format!("http://{addr}")
}
#[tokio::test]
async fn session_stop_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_stop(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
"nonexistent-id".to_string(),
)
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
#[tokio::test]
async fn session_resume_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_resume(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
"nonexistent-id".to_string(),
)
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
async fn spawn_test_daemon_with_unrestartable_stopped_session() -> (String, String) {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-resume-missing-ws"));
let mgr = state.session_manager().await;
mgr.create_with_id(
id,
"regression: #2649 resume-restart-failure CLI test".to_string(),
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 session");
mgr.stop(&id).await.expect("mark seeded session Stopped");
assert!(
!ws.exists(),
"#3715: stop()'s snapshot capture must NOT recreate a vanished workspace root"
);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
(format!("http://{addr}"), id.to_string())
}
#[tokio::test]
async fn session_resume_restart_failure_errors() {
let (url, id) = spawn_test_daemon_with_unrestartable_stopped_session().await;
let client = reqwest::Client::new();
let err = session_resume(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
id,
)
.await
.expect_err("a daemon-rejected restart must be a hard failure, not Ok(())");
assert!(
err.to_string().contains("cannot restart"),
"error should surface the daemon's rejection: {err}"
);
}
#[tokio::test]
async fn session_resume_headless_active_live_tmux_skips_restart_and_attach() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-headless-attach-ws"));
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
id,
"regression: #2649 headless active-live-tmux CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some(format!("https://example.com/r{}.git", std::process::id())),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
assert_eq!(
record.state.to_string(),
"provisioning",
"sanity: create_with_id must leave a fresh record un-stopped/un-errored"
);
let tmux_bin = trusty_mpm::core::tmux::resolve_tmux_binary_or_bare();
let server = PrivateTmuxServer::new(&tmux_bin, "resume-live");
let shim = server.shim_bin();
let scratch = ScratchTmuxSession::spawn_on_socket(
&tmux_bin,
Some(server.name()),
&record.tmux_name,
"sleep 300",
);
wait_for_stable_live_runtime(&shim, &record.tmux_name);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let result = with_tmux_binary(
shim.into(),
session_resume(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
id.to_string(),
),
)
.await;
drop(scratch);
result.expect(
"headless resume of an already-live, non-stopped/errored session must succeed (Ok), \
not error",
);
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
after.get("state").and_then(|v| v.as_str()),
Some("provisioning"),
"state must be UNCHANGED — a /resume POST would have flipped it to 'active'; this \
proves the Attach branch never issued a daemon-side restart"
);
}
fn wait_for_server_answer(tmux_bin: &str, session_name: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let target = exact_session_target(session_name.trim());
while std::time::Instant::now() < deadline {
let answered = std::process::Command::new(tmux_bin)
.args(["has-session", "-t", &target])
.output()
.is_ok_and(|out| out.status.success());
if answered {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!(
"fixture precondition: the tmux server behind '{tmux_bin}' never answered \
for session '{session_name}' within 60s"
);
}
fn wait_for_stable_live_runtime(tmux_bin: &str, session_name: &str) {
const REQUIRED_AGREEING_READS: u32 = 3;
wait_for_server_answer(tmux_bin, session_name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut streak = 0;
while std::time::Instant::now() < deadline {
if crate::commands::guided_resume::session_runtime_live_with_bin(tmux_bin, session_name) {
streak += 1;
if streak == REQUIRED_AGREEING_READS {
return;
}
} else {
streak = 0;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!(
"fixture precondition: tmux session '{session_name}' never settled into a \
stable live-runtime state within 10s; this test would silently exercise \
the #3873 reconcile branch instead of the Attach branch it asserts about"
);
}
fn wait_for_stable_dead_runtime(tmux_bin: &str, session_name: &str) {
const REQUIRED_AGREEING_READS: u32 = 3;
wait_for_server_answer(tmux_bin, session_name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut streak = 0;
while std::time::Instant::now() < deadline {
if crate::commands::guided_resume::session_runtime_live_with_bin(tmux_bin, session_name) {
streak = 0;
} else {
streak += 1;
if streak == REQUIRED_AGREEING_READS {
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!(
"fixture precondition: tmux session '{session_name}' never settled into a \
stable dead-runtime state within 10s; this test cannot distinguish the \
#3873 reconcile branch from Attach without it"
);
}
#[tokio::test]
async fn session_resume_headless_dead_runtime_reconciles_and_restarts() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-dead-runtime-ws"));
std::fs::create_dir_all(&ws).expect("create workspace dir");
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
id,
"regression: #3873 dead-runtime reconcile CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some(format!(
"https://example.com/{}.git",
crate::test_support::tmux_session::reserved_project_slug(&format!(
"deadrt{}",
std::process::id()
))
)),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
assert_eq!(
record.state.to_string(),
"provisioning",
"sanity: create_with_id must leave a fresh record un-stopped/un-errored"
);
assert!(
trusty_common::session_naming::is_reserved_test_session_name(&record.tmux_name),
"this fixture's real tmux session must be one the daemon refuses to adopt, got {}",
record.tmux_name
);
let tmux_bin = trusty_mpm::core::tmux::resolve_tmux_binary_or_bare();
let server = PrivateTmuxServer::new(&tmux_bin, "resume-dead");
let shim = server.shim_bin();
let scratch = ScratchTmuxSession::spawn_on_socket(
&tmux_bin,
Some(server.name()),
&record.tmux_name,
"sh",
);
wait_for_stable_dead_runtime(&shim, &record.tmux_name);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let result = with_tmux_binary(
shim.into(),
session_resume(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
id.to_string(),
),
)
.await;
drop(scratch);
let _ = result;
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let persisted = after
.get("persisted_state")
.and_then(|v| v.as_str())
.expect("daemon must report persisted_state");
assert_ne!(
persisted, "provisioning",
"the record must have been reconciled and restarted — a /runtime-stop + \
/resume round-trip moves it off 'provisioning' (to 'active', or \
'errored' where no runtime binary exists). Reading 'provisioning' means \
the CLI attached into the idle shell instead, i.e. the #3873 defect is back"
);
}
#[tokio::test]
async fn session_resume_zombie_active_tmux_absent_reconciles_and_restarts() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::{ManagedSessionId, ManagedSessionState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-zombie-ws"));
tokio::fs::create_dir_all(&ws)
.await
.expect("create a REAL workspace dir so the reconcile/restart attempt is not blocked");
let mgr = state.session_manager().await;
mgr.create_with_id(
id,
"regression: #3531 zombie-restart CLI test".to_string(),
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 session");
mgr.set_workspace(&id, ws, ManagedSessionState::Active)
.await
.expect("force record to Active (simulating a zombie)");
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let before: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
before.get("state").and_then(|v| v.as_str()),
Some("stopped"),
"sanity: the list/get endpoint's display reconciliation must show \
'stopped' for an Active record with no live tmux (the #3302 behavior)"
);
assert_eq!(
before.get("persisted_state").and_then(|v| v.as_str()),
Some("active"),
"sanity: persisted_state must still carry the TRUE record state"
);
if let Err(e) = session_resume(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
id.to_string(),
)
.await
{
let msg = e.to_string();
assert!(
!msg.contains("cannot resume a session in state"),
"session_resume must never dead-end on the daemon's raw 409 for a \
zombie session — the #3531 regression: {msg}"
);
}
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_ne!(
after.get("persisted_state").and_then(|v| v.as_str()),
None,
"persisted_state must still be present on the wire"
);
let final_state = after.get("persisted_state").and_then(|v| v.as_str());
assert!(
matches!(final_state, Some("active") | Some("errored")),
"a genuine reconcile+restart attempt must land the record in 'active' \
(spawn succeeded) or 'errored' (spawn failed for environment reasons) \
— never left untouched; got persisted_state={final_state:?}"
);
}
#[test]
fn session_decommission_exits_non_zero_when_the_workspace_is_kept() {
let err = super::decommission_kept_error(Some("the dirty-tree guard kept it"))
.expect("a kept workspace must fail the command");
assert!(
err.to_string().contains("the dirty-tree guard kept it"),
"{err}"
);
assert!(super::decommission_kept_error(None).is_none());
}
fn git_ok(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("run git");
assert!(
out.status.success(),
"`git {}`: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
fn pushed_repo_7660() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tmp");
let root = std::fs::canonicalize(tmp.path()).expect("canon");
let (remote, repo) = (root.join("remote.git"), root.join("repo"));
std::fs::create_dir_all(&remote).expect("mkdir remote");
std::fs::create_dir_all(&repo).expect("mkdir repo");
git_ok(&remote, &["init", "--bare", "--initial-branch=main"]);
git_ok(&repo, &["init", "--initial-branch=main"]);
for (k, v) in [
("user.email", "ci@test.invalid"),
("user.name", "CI"),
("commit.gpgsign", "false"),
] {
git_ok(&repo, &["config", k, v]);
}
std::fs::write(repo.join(".gitignore"), "target/\n").expect("write .gitignore");
git_ok(&repo, &["add", ".gitignore"]);
git_ok(&repo, &["commit", "-m", "base"]);
git_ok(
&repo,
&["remote", "add", "origin", remote.to_str().expect("utf8")],
);
git_ok(&repo, &["push", "origin", "main"]);
git_ok(&repo, &["fetch", "origin"]);
(tmp, root, repo)
}
fn admin_path_7660(wt: &std::path::Path, name: &str) -> std::path::PathBuf {
let out = std::process::Command::new("git")
.arg("-C")
.arg(wt)
.args(["rev-parse", "--path-format=absolute", "--git-path", name])
.output()
.expect("run git rev-parse");
assert!(out.status.success(), "git rev-parse --git-path failed");
String::from_utf8(out.stdout)
.expect("utf8 path")
.trim()
.into()
}
fn mark_owned_7660(wt: &std::path::Path) {
std::fs::write(admin_path_7660(wt, "trusty-mpm-worktree"), b"")
.expect("write the admin-dir marker");
}
fn record_ledger_7660(wt: &std::path::Path) {
use sha2::{Digest as _, Sha256};
let files: serde_json::Map<String, serde_json::Value> = [
"CLAUDE.md",
".claude/settings.json",
".claude/settings.json.bak",
]
.into_iter()
.map(|rel| {
let bytes = std::fs::read(wt.join(rel)).expect("read a provisioned file");
(
rel.to_string(),
format!("{:x}", Sha256::digest(bytes)).into(),
)
})
.collect();
let ledger = serde_json::json!({ "version": 1, "files": files, "gitignore_appended": [] });
std::fs::write(
admin_path_7660(wt, "trusty-mpm-provisioning-ledger.json"),
serde_json::to_vec(&ledger).expect("serialize the ledger"),
)
.expect("write the ledger");
}
fn provision_dirt_7660(ws: &std::path::Path) {
use trusty_mpm::core::scaffold_gitignore::{
SCAFFOLD_GITIGNORE_BEGIN, SCAFFOLD_GITIGNORE_END, SCAFFOLD_IGNORED_PATHS,
};
let mut gitignore = std::fs::read_to_string(ws.join(".gitignore")).unwrap_or_default();
if !gitignore.is_empty() {
gitignore.push('\n');
}
gitignore.push_str(&format!(
"{SCAFFOLD_GITIGNORE_BEGIN}\n{}\n{SCAFFOLD_GITIGNORE_END}\n",
SCAFFOLD_IGNORED_PATHS.join("\n")
));
std::fs::write(ws.join(".gitignore"), gitignore).expect("scaffold");
std::fs::create_dir_all(ws.join(".claude")).expect("mkdir .claude");
std::fs::write(ws.join(".claude/settings.json"), "{}\n").expect("settings");
std::fs::write(ws.join(".claude/settings.json.bak"), "{}\n").expect("settings bak");
std::fs::write(ws.join("CLAUDE.md"), "# tm\n").expect("CLAUDE.md");
}
async fn serve_session_on_7660(root: &std::path::Path, ws: &std::path::Path) -> (String, String) {
use trusty_mpm::daemon::{api, state::DaemonState};
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.join("sm")).await);
let id = trusty_mpm::session_manager::ManagedSessionId::new();
state
.session_manager()
.await
.create_with_id(
id,
"regression: #7660 decommission end to end".to_string(),
Some(ws.to_path_buf()),
None,
Some(ws.to_path_buf()),
None,
None,
trusty_mpm::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(axum::serve(listener, api::router(state)).into_future());
(format!("http://{addr}"), id.to_string())
}
type Served7660 = (String, String, std::path::PathBuf, tempfile::TempDir);
async fn spawn_daemon_with_provisioned_worktree() -> Served7660 {
let (tmp, root, repo) = pushed_repo_7660();
let wt = repo.join(".worktrees").join("decom-7660");
git_ok(
&repo,
&[
"worktree",
"add",
"-b",
"session/decom-7660",
wt.to_str().expect("utf8"),
],
);
mark_owned_7660(&wt);
provision_dirt_7660(&wt);
record_ledger_7660(&wt);
let (url, id) = serve_session_on_7660(&root, &wt).await;
(url, id, wt, tmp)
}
async fn spawn_daemon_on_the_main_checkout() -> Served7660 {
let (tmp, root, repo) = pushed_repo_7660();
provision_dirt_7660(&repo);
let (url, id) = serve_session_on_7660(&root, &repo).await;
(url, id, repo, tmp)
}
#[tokio::test]
async fn session_decommission_routed_fails_naming_why_the_workspace_was_kept() {
let (url, id, wt, _tmp) = spawn_daemon_with_provisioned_worktree().await;
let client = reqwest::Client::new();
let err = super::session_decommission_routed(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
&id,
false,
)
.await
.expect_err("a kept workspace must fail the command");
let msg = err.to_string();
assert!(msg.contains("workspace NOT removed"), "{msg}");
assert!(
msg.contains("--force"),
"the reason must name the flag: {msg}"
);
assert!(wt.exists(), "the workspace must still be on disk");
}
#[tokio::test]
async fn session_decommission_routed_force_removes_a_provisioning_only_worktree() {
let (url, id, wt, _tmp) = spawn_daemon_with_provisioned_worktree().await;
let client = reqwest::Client::new();
super::session_decommission_routed(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
&id,
true,
)
.await
.expect("--force removes a provisioning-only tree");
assert!(!wt.exists(), "the workspace directory must be gone");
}
#[tokio::test]
async fn session_decommission_routed_force_keeps_user_work_naming_the_file() {
let (url, id, wt, _tmp) = spawn_daemon_with_provisioned_worktree().await;
std::fs::write(wt.join("notes.rs"), "// unsaved\n").expect("user work");
let client = reqwest::Client::new();
let err = super::session_decommission_routed(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
&id,
true,
)
.await
.expect_err("user work must fail the command");
let msg = err.to_string();
assert!(msg.contains("notes.rs"), "the file must be named: {msg}");
assert!(wt.join("notes.rs").exists(), "the user's file must survive");
}
#[tokio::test]
async fn session_decommission_routed_keeps_the_shared_main_checkout_under_force() {
let (url, id, repo, _tmp) = spawn_daemon_on_the_main_checkout().await;
let client = reqwest::Client::new();
let err = super::session_decommission_routed(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
&id,
true,
)
.await
.expect_err("a kept main checkout must fail the command");
let msg = err.to_string();
assert!(msg.contains("main checkout"), "{msg}");
assert!(msg.contains("--force does not apply"), "{msg}");
assert!(repo.join(".git").is_dir(), "the main checkout must survive");
assert!(repo.join("CLAUDE.md").exists(), "nothing in it is removed");
}
#[tokio::test]
async fn session_decommission_routed_says_why_it_kept_the_main_checkout() {
let (url, id, repo, _tmp) = spawn_daemon_on_the_main_checkout().await;
let client = reqwest::Client::new();
let outcome = super::super::managed_route::executor(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
)
.decommission_managed_target(&id, false)
.await
.expect("decommission");
let printed = super::decommission_report(&outcome);
let why = printed.lines().nth(1).expect("a by-design reason line");
assert!(why.contains("kept by design"), "{printed}");
assert!(why.contains("main checkout"), "{printed}");
assert_eq!(printed.lines().count(), 2, "one reason line: {printed}");
assert!(
super::decommission_kept_error(outcome.workspace_kept_reason.as_deref()).is_none(),
"a by-design keep is not a failure"
);
let (url, id, repo2, _tmp2) = spawn_daemon_on_the_main_checkout().await;
super::session_decommission_routed(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
&id,
false,
)
.await
.expect("a by-design keep exits 0");
assert!(repo.exists() && repo2.join(".git").is_dir());
}
#[tokio::test]
async fn decommission_report_says_nothing_was_on_disk_for_an_absent_workspace() {
let (url, id, wt, _tmp) = spawn_daemon_with_provisioned_worktree().await;
std::fs::remove_dir_all(&wt).expect("remove the worktree out of band");
let client = reqwest::Client::new();
let outcome = super::super::managed_route::executor(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
)
.decommission_managed_target(&id, false)
.await
.expect("decommission");
let printed = super::decommission_report(&outcome);
assert!(printed.contains("no workspace was on disk"), "{printed}");
assert!(!printed.contains("still on disk"), "{printed}");
assert!(
super::decommission_kept_error(outcome.workspace_kept_reason.as_deref()).is_none(),
"an absent workspace is not a refusal"
);
}
#[tokio::test]
async fn session_decommission_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_decommission(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
"nonexistent-id".to_string(),
)
.await
.expect_err("a malformed id must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the rejected id: {err}"
);
let absent = "11111111-2222-3333-4444-555555555555";
let err = session_decommission(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
absent.to_string(),
)
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert_eq!(
err.to_string(),
format!("managed session '{absent}' not found"),
"the 404 mapping must survive: prune's sweep counts this Err as a failed row"
);
}
async fn spawn_decommission_stub(workspace_removed: Option<bool>) -> String {
use axum::routing::{get, post};
let list = serde_json::json!({
"sessions": [{ "id": STUB_ID, "name": "tm-5913", "state": "active" }]
});
let outcome = serde_json::json!({
"id": STUB_ID,
"name": "tm-5913",
"state": "decommissioned",
"workspace_removed": workspace_removed,
"workspace_path_was": serde_json::Value::Null,
});
let app = axum::Router::new()
.route(
"/api/v1/sessions/managed",
get(move || std::future::ready(axum::Json(list.clone()))),
)
.route(
"/api/v1/sessions/managed/{id}/decommission",
post(move || std::future::ready(axum::Json(outcome.clone()))),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, app).into_future());
format!("http://{addr}")
}
const STUB_ID: &str = "11111111-2222-3333-4444-555555555555";
#[tokio::test]
async fn decommission_entry_points_agree_on_every_verdict() {
use trusty_mpm::client::{CommandExecutor, TrustyCommand};
for verdict in [Some(true), Some(false), None] {
let url = spawn_decommission_stub(verdict).await;
let client = reqwest::Client::new();
let bulk = super::session_decommission_line(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
STUB_ID,
)
.await
.unwrap_or_else(|e| panic!("verdict {verdict:?}: bulk path failed: {e}"));
let routed = super::super::managed_route::render_cli(
&CommandExecutor::with_client(client.clone(), url.clone())
.execute(TrustyCommand::ManagedDecommission {
target: STUB_ID.to_string(),
})
.await,
);
assert_eq!(
bulk, routed,
"verdict {verdict:?}: the two entry points disagree"
);
assert_eq!(
bulk,
super::super::managed_route::decommission_message(STUB_ID, verdict),
"verdict {verdict:?}: neither path may invent its own wording"
);
}
}
#[tokio::test]
async fn session_activity_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_activity(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url.to_string()),
"nonexistent-id".to_string(),
)
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
fn ls_session(name: &str, slot: u32) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: "11111111-2222-3333-4444-555555555555".into(),
name: name.to_string(),
state: "active".into(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: Some("2026-08-05T12:34:56Z".into()),
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: Some("do the thing".into()),
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot,
deleted: false,
auto_resume_parked: None,
kind: None,
}
}
fn uncolored_state_session() -> trusty_mpm::client::ManagedSessionSummary {
let mut s = ls_session("tm-trusty-tools-01", 7);
s.state = "some-future-state".into();
s
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn ls_row_colors_num_and_name_in_distinct_hues() {
let row = format_ls_row(&uncolored_state_session(), true, 14, None);
assert!(
row.starts_with("\u{1b}[35m7\u{1b}[0m"),
"NUM colored: {row:?}"
);
assert!(
row.contains("\u{1b}[36mtm-trusty-tools-01\u{1b}[0m"),
"NAME colored: {row:?}"
);
assert_ne!(
"\u{1b}[35m", "\u{1b}[36m",
"NUM and NAME must not share a hue"
);
}
#[test]
fn ls_row_plain_when_color_disabled() {
let s = ls_session("tm-trusty-tools-01", 7);
let plain = format_ls_row(&s, false, 14, None);
assert!(!plain.contains('\u{1b}'), "no escapes: {plain:?}");
assert_eq!(
strip_ansi(&format_ls_row(&s, true, 14, None)),
plain,
"color must change bytes only inside the escapes"
);
let tomb_plain = format_tombstone_row(7, false);
assert!(!tomb_plain.contains('\u{1b}'));
assert_eq!(strip_ansi(&format_tombstone_row(7, true)), tomb_plain);
}
#[test]
fn ls_row_alignment_matches_with_and_without_color() {
for name in ["a", "tm-trusty-tools-01", &"x".repeat(60)] {
for slot in [1u32, 107] {
let s = ls_session(name, slot);
assert_eq!(
strip_ansi(&format_ls_row(&s, true, 14, None))
.chars()
.count(),
format_ls_row(&s, false, 14, None).chars().count(),
"row width drifts for name={name:?} slot={slot}"
);
}
}
for slot in [1u32, 107] {
assert_eq!(
strip_ansi(&format_tombstone_row(slot, true))
.chars()
.count(),
format_tombstone_row(slot, false).chars().count(),
"tombstone row width drifts for slot={slot}"
);
let plain = format_tombstone_row(slot, false);
assert_eq!(
&plain[..7],
&format_ls_row(&ls_session("n", slot), false, 14, None)[..7],
"tombstone NUM column matches the live row's"
);
}
}
#[test]
fn ls_row_colors_id_column_dimmed() {
let s = uncolored_state_session();
let row = format_ls_row(&s, true, 14, None);
assert!(
row.contains(&format!("\u{1b}[2m{}\u{1b}[0m", s.id)),
"ID colored dim: {row:?}"
);
let hues = ["\u{1b}[35m", "\u{1b}[2m", "\u{1b}[36m"];
for (i, a) in hues.iter().enumerate() {
for b in &hues[i + 1..] {
assert_ne!(a, b, "NUM/ID/NAME must not share a hue");
}
}
}
#[test]
fn state_column_width_absorbs_longest_annotation() {
let plain = vec![ls_session("a", 1), ls_session("b", 2)];
assert_eq!(
state_column_width(&plain),
14,
"an all-plain listing keeps the historical width"
);
let mut stale = ls_session("c", 3);
stale.attached = true;
stale.stale_assets = true;
let annotated = vec![ls_session("a", 1), stale];
assert_eq!(
state_column_width(&annotated),
"attached [stale-assets]".len()
);
let mut tomb = ls_session("d", 4);
tomb.deleted = true;
tomb.state = "a-very-long-state-string-that-is-not-rendered".into();
assert_eq!(
state_column_width(&[tomb]),
14,
"a tombstone row has no STATE cell and must not widen the column"
);
}
#[test]
fn ls_table_columns_align_when_a_row_carries_an_annotation() {
let plain = ls_session("plain-name", 1);
let mut annotated = ls_session("stale-name", 2);
annotated.attached = true;
annotated.stale_assets = true;
let sessions = vec![plain.clone(), annotated.clone()];
let width = state_column_width(&sessions);
let name_offset = |row: &str, name: &str| row.find(name).expect("name present in row");
for use_color in [false, true] {
let a = strip_ansi(&format_ls_row(&plain, use_color, width, None));
let b = strip_ansi(&format_ls_row(&annotated, use_color, width, None));
assert_eq!(
name_offset(&a, "plain-name"),
name_offset(&b, "stale-name"),
"NAME column drifts between a plain and an annotated row (use_color={use_color})"
);
}
assert!(format_ls_row(&annotated, false, width, None).contains("attached [stale-assets]"));
for s in [&plain, &annotated] {
assert_eq!(
strip_ansi(&format_ls_row(s, true, width, None)),
format_ls_row(s, false, width, None),
"padding must be measured on visible text, not the ANSI-wrapped string"
);
}
}
fn state_color_cases() -> Vec<(
trusty_mpm::client::ManagedSessionSummary,
Option<&'static str>,
)> {
let with = |state: &str, edit: fn(&mut trusty_mpm::client::ManagedSessionSummary)| {
let mut s = ls_session("tm-trusty-tools-01", 7);
s.state = state.into();
edit(&mut s);
s
};
vec![
(with("active", |_| {}), Some("32")),
(with("stopped", |_| {}), Some("33")),
(with("errored", |_| {}), Some("31")),
(with("stopped", |s| s.unresumable = true), Some("31")),
(with("active", |s| s.attached = true), Some("1;36")),
(with("provisioning", |_| {}), Some("34")),
(with("decommissioned", |_| {}), Some("2")),
(with("some-future-state", |_| {}), None),
]
}
#[test]
fn ls_rows_are_painted_whole_in_their_state_color() {
for (s, param) in state_color_cases() {
let plain = format_ls_row(&s, false, 14, None);
let colored = format_ls_row(&s, true, 14, None);
match param {
Some(p) => assert_eq!(
colored,
format!("\u{1b}[{p}m{plain}\u{1b}[0m"),
"state {:?} (unresumable={}, attached={})",
s.state,
s.unresumable,
s.attached
),
None => assert!(
colored.starts_with("\u{1b}[35m7\u{1b}[0m"),
"an unmapped state keeps the NUM column hue: {colored:?}"
),
}
}
assert_eq!(
format_tombstone_row(7, true),
format!("\u{1b}[31m{}\u{1b}[0m", format_tombstone_row(7, false)),
"a tombstone row is painted red"
);
}
#[test]
fn ls_row_state_colors_never_reach_a_plain_table() {
for (s, _) in state_color_cases() {
let plain = format_ls_row(&s, false, 14, None);
assert!(!plain.contains('\u{1b}'), "no escapes: {plain:?}");
assert_eq!(
strip_ansi(&format_ls_row(&s, true, 14, None)),
plain,
"state {:?}: color must change bytes only inside the escapes",
s.state
);
}
assert!(!format_tombstone_row(7, false).contains('\u{1b}'));
}
#[test]
fn ls_row_renders_the_startup_context_column() {
let s = ls_session("tm-trusty-tools-01", 7);
let measured = format_ls_row(&s, false, 14, Some(98_214));
let unmeasured = format_ls_row(&s, false, 14, None);
assert!(
measured.contains("98k"),
"the reading is shown: {measured:?}"
);
assert!(
!unmeasured.contains('k'),
"an unrecorded session shows no figure: {unmeasured:?}"
);
assert_eq!(
measured.chars().count(),
unmeasured.chars().count(),
"the START column keeps its width with and without a reading"
);
}
#[test]
fn session_table_pins_and_tags_the_architect_row() {
use crate::commands::managed_render::{kind_tag, pinned_first};
use trusty_mpm::session_manager::SessionKind;
let with = |name: &str, kind: Option<SessionKind>, deleted: bool| {
let mut s = ls_session(name, 1);
s.kind = kind;
s.deleted = deleted;
s
};
let rows = [
with("ordinary", None, false),
with("gone-arch", Some(SessionKind::Supervisor), true),
with("poller", Some(SessionKind::SupervisorAux), false),
with("arch", Some(SessionKind::Supervisor), false),
];
let order: Vec<&str> = pinned_first(&rows)
.iter()
.map(|s| s.name.as_str())
.collect();
assert_eq!(order, ["arch", "poller", "ordinary", "gone-arch"]);
assert!(format_ls_row(&rows[3], false, 14, None).contains("active [architect]"));
assert!(format_ls_row(&rows[2], false, 14, None).contains("[architect-helper]"));
assert!(!format_ls_row(&rows[0], false, 14, None).contains('['));
assert_eq!(kind_tag(Some(SessionKind::Unknown)), Some("protected"));
assert_eq!(kind_tag(Some(SessionKind::Ordinary)), None);
}
#[test]
fn startup_cell_distinguishes_unmeasured_from_tiny() {
use crate::commands::managed_render::format_startup_cell;
assert_eq!(format_startup_cell(None), "-");
assert_eq!(format_startup_cell(Some(0)), "0k");
assert_eq!(format_startup_cell(Some(499)), "0k");
assert_eq!(format_startup_cell(Some(500)), "1k");
assert_eq!(format_startup_cell(Some(101_986)), "102k");
}
#[test]
fn decommission_ephemeral_refuses_a_dry_run_a_stale_daemon_ignored() {
use super::ephemeral_sweep_line;
let missing = serde_json::json!({ "decommissioned": 7 });
let err = ephemeral_sweep_line(&missing, true).expect_err("a silent daemon must fail the run");
let msg = err.to_string();
assert!(
msg.contains("did not confirm --dry-run") && msg.contains('7'),
"the refusal must say what may have been torn down: {msg}"
);
let denied = serde_json::json!({ "decommissioned": 2, "dry_run": false });
assert!(
ephemeral_sweep_line(&denied, true).is_err(),
"an explicit `dry_run: false` is the same skew, stated outright"
);
let real = ephemeral_sweep_line(&missing, false).expect("a real sweep needs no echo");
assert_eq!(real, "decommissioned 7 ephemeral session(s)");
}
#[test]
fn decommission_ephemeral_reports_a_confirmed_dry_run() {
use super::ephemeral_sweep_line;
let body = serde_json::json!({ "decommissioned": 3, "dry_run": true });
assert_eq!(
ephemeral_sweep_line(&body, true).expect("a confirmed preview renders"),
"would decommission 3 ephemeral session(s) (dry run)"
);
}