use std::sync::Arc;
use axum::{Json, extract::State, response::IntoResponse};
use serde::Deserialize;
use tracing::warn;
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::state::DaemonState;
use crate::session_manager::prune::OrphanSweepOutcome;
use crate::session_manager::worktree_reclaim::ReclaimMode;
use crate::session_manager::worktree_reclaim_preview::preview_rows;
use crate::session_manager::worktree_scope::PruneScope;
use crate::session_manager::{DirtyWorktreePolicy, PruneFilter};
#[derive(Debug, Deserialize)]
pub struct PruneRequest {
pub state: String,
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub include_active: bool,
#[serde(default)]
pub invoking_session: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct EphemeralQuery {
#[serde(default)]
pub dry_run: bool,
}
pub async fn decommission_ephemeral_route(
State(state): State<Arc<DaemonState>>,
axum::extract::Query(q): axum::extract::Query<EphemeralQuery>,
) -> impl IntoResponse {
decommission_ephemeral_core(&state, q.dry_run).await }
pub(crate) async fn decommission_ephemeral_core(
state: &Arc<DaemonState>,
dry_run: bool,
) -> RouteOutcome {
let mgr = state.session_manager().await;
match mgr.sweep_all_ephemeral(dry_run).await {
Ok(outcome) => RouteOutcome::ok(&serde_json::json!({
"decommissioned": outcome.count(),
"dry_run": dry_run,
})),
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
#[derive(Debug, Deserialize)]
pub struct PruneWorktreesRequest {
#[serde(default = "default_dry_run")]
pub dry_run: bool,
#[serde(default)]
pub discard_dirty: bool,
#[serde(default)]
pub merged_prs: bool,
#[serde(default)]
pub invoking_session: Option<String>,
#[serde(default)]
pub project_root: Option<String>,
#[serde(default)]
pub only_orphan_paths: Option<Vec<String>>,
#[serde(default)]
pub only_merged_paths: Option<Vec<String>>,
#[serde(default)]
pub only_discard_paths: Option<Vec<String>>,
}
fn default_dry_run() -> bool {
true
}
pub async fn prune_worktrees_route(
State(state): State<Arc<DaemonState>>,
Json(req): Json<PruneWorktreesRequest>,
) -> impl IntoResponse {
prune_worktrees_core(&state, req).await }
pub(crate) async fn prune_worktrees_core(
state: &Arc<DaemonState>,
req: PruneWorktreesRequest,
) -> RouteOutcome {
if let Some(raw) = req.invoking_session.as_deref()
&& raw.parse::<uuid::Uuid>().is_err()
{
let msg = "invalid invoking_session: not a UUID — refusing the prune rather than \
running it without the requested caller attribution (#6806)"
.to_string();
warn!("prune-worktrees route: {msg}");
return RouteOutcome::text(400, msg);
}
let scope = match PruneScope::from_request(
req.project_root.as_deref(),
req.only_orphan_paths.as_deref(),
req.only_merged_paths.as_deref(),
req.only_discard_paths.as_deref(),
) {
Ok(scope) => scope,
Err(msg) => {
warn!("prune-worktrees route: {msg}");
return RouteOutcome::text(400, msg);
}
};
let mgr = state.session_manager().await;
let records = mgr.list().await;
let in_use_workspace_paths: Vec<std::path::PathBuf> = records
.iter()
.filter_map(|r| r.workspace_path.clone())
.collect();
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let repos_root = crate::core::trusty_tools_config::workspace_root(&config);
let policy = if req.discard_dirty {
DirtyWorktreePolicy::ForceDiscard
} else {
DirtyWorktreePolicy::Skip
};
let adopted = crate::project::adopted_anchors_under(state.framework_root());
let project_known = scope.orphan.project_known(&repos_root, &adopted);
match mgr
.prune_orphaned_worktrees_in(
&repos_root,
&in_use_workspace_paths,
req.dry_run,
policy,
&adopted,
&scope.orphan,
)
.await
{
Ok(outcome) => {
let paths: Vec<String> = outcome
.removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let owner_unknown_paths: Vec<String> = outcome
.owner_unknown
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let agent_owned_paths: Vec<String> = outcome
.agent_owned
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let merged = if req.merged_prs {
let mode = if req.dry_run {
ReclaimMode::Report
} else {
ReclaimMode::Remove
};
match crate::daemon::services::merged_pr_reclaim::reclaim(
state,
&repos_root,
mode,
req.invoking_session.clone(),
scope.merged.clone(),
)
.await
{
Ok(o) => {
let preview = preview_rows(&o.survey);
serde_json::json!({
"reclaimable_paths": preview.reclaim,
"unknown_paths": preview.unknown,
"removed": o.removed,
"removed_bytes": o.removed_bytes,
"refused_at_recheck": o.refused_at_recheck,
"removal_failed": o.removal_failed,
"partially_removed": o.partially_removed,
"spared_agent_owned": o.survey.agent_owned,
"blocked_reasons": o.survey.blocked_reasons,
"reclaimable": o.survey.reclaimable,
"reclaimable_measured": o.survey.reclaimable_measured,
"reclaimable_bytes": o.survey.reclaimable_bytes,
"total_bytes": o.survey.total_bytes,
"pr_state_unknown": o.survey.pr_state_unknown,
"not_inspected": o.survey.not_inspected,
"lookup_failed": o.survey.lookup_failed,
"lookup_failure": o.survey.lookup_failure,
})
}
Err(e) => {
warn!("prune-worktrees route: merged-PR pass panicked: {e}");
serde_json::json!({ "error": e })
}
}
} else {
serde_json::Value::Null
};
RouteOutcome::ok(&serde_json::json!({
"dry_run": req.dry_run,
"scope": scope.echo(project_known),
"orphan_rows": orphan_rows(&outcome),
"paths": paths,
"owner_unknown_paths": owner_unknown_paths,
"agent_owned_paths": agent_owned_paths,
"skipped_dirty": outcome.skipped_dirty,
"partially_removed": outcome.partially_removed,
"merged_prs": merged,
}))
}
Err(e) => {
warn!("prune-worktrees route: orphan scan failed: {e}");
RouteOutcome::text(500, format!("orphan worktree scan failed: {e}"))
}
}
}
pub(crate) fn orphan_rows(outcome: &OrphanSweepOutcome) -> Vec<serde_json::Value> {
outcome
.removed
.iter()
.map(|path| {
let project = outcome.registry_roots.get(path).map_or_else(
|| "(owning checkout unresolved)".to_string(),
|r| r.to_string_lossy().into_owned(),
);
let dirt = outcome.discarded_dirty.iter().find(|d| d.path == *path);
let reason = match dirt {
Some(dirt) => format!(
"orphaned — its owning session has ended; it holds unsaved work ({}) — \
discarded (--discard-dirty)",
dirt.reason
),
None => {
"orphaned — its owning session has ended and it holds no unsaved work".into()
}
};
serde_json::json!({
"path": path.to_string_lossy(),
"project": project,
"reason": reason,
"discards_unsaved_work": dirt.is_some(),
})
})
.collect()
}
pub async fn prune_managed_route(
State(state): State<Arc<DaemonState>>,
Json(req): Json<PruneRequest>,
) -> impl IntoResponse {
prune_managed_core(&state, req).await }
pub(crate) async fn prune_managed_core(
state: &Arc<DaemonState>,
req: PruneRequest,
) -> RouteOutcome {
let filter = match PruneFilter::parse(&req.state) {
Ok(f) => f,
Err(e) => {
warn!("prune route: {e}");
return RouteOutcome::text(400, e);
}
};
let invoker = match req
.invoking_session
.as_deref()
.map(str::parse::<uuid::Uuid>)
{
None => None,
Some(Ok(u)) => Some(crate::session_manager::record::ManagedSessionId::from(u)),
Some(Err(_)) => {
let msg = "invalid invoking_session: not a UUID — refusing the prune rather than \
running it without the requested self-exclusion (#6118)"
.to_string();
warn!("prune route: {msg}");
return RouteOutcome::text(400, msg);
}
};
let mgr = state.session_manager().await;
match mgr
.prune_managed(filter, req.dry_run, req.include_active, None, invoker)
.await
{
Ok(outcome) => RouteOutcome::ok(&outcome),
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[test]
fn prune_worktrees_request_defaults_to_skip_dirty() {
let req: PruneWorktreesRequest = serde_json::from_str("{}").expect("empty body parses");
assert!(req.dry_run, "an unspecified prune must preview, not delete");
assert!(
!req.discard_dirty,
"an unspecified prune must NEVER discard uncommitted work"
);
assert!(
!req.merged_prs,
"an unspecified prune must NEVER run the merged-PR reclaim pass"
);
let explicit: PruneWorktreesRequest =
serde_json::from_str(r#"{"dry_run":false,"discard_dirty":true,"merged_prs":true}"#)
.expect("explicit body parses");
assert!(explicit.discard_dirty, "the opt-in must round-trip");
assert!(explicit.merged_prs, "the #2919 opt-in must round-trip");
}
#[tokio::test]
async fn prune_worktrees_route_rejects_an_unparseable_invoking_session() {
let root = crate::test_support::hermetic_temp_dir();
let state =
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
let bad = prune_worktrees_route(
State(state.clone()),
Json(PruneWorktreesRequest {
dry_run: true,
discard_dirty: false,
merged_prs: false,
invoking_session: Some("not-a-uuid".into()),
project_root: None,
only_orphan_paths: None,
only_merged_paths: None,
only_discard_paths: None,
}),
)
.await
.into_response();
assert_eq!(
bad.status(),
StatusCode::BAD_REQUEST,
"a malformed caller id must stop the sweep, not be dropped"
);
let ok = prune_worktrees_route(
State(state),
Json(PruneWorktreesRequest {
dry_run: true,
discard_dirty: false,
merged_prs: false,
invoking_session: None,
project_root: None,
only_orphan_paths: None,
only_merged_paths: None,
only_discard_paths: None,
}),
)
.await
.into_response();
assert_ne!(
ok.status(),
StatusCode::BAD_REQUEST,
"an absent caller id is legitimate and must be accepted"
);
}
#[tokio::test]
async fn prune_worktrees_route_rejects_an_unresolvable_project_root() {
let root = crate::test_support::hermetic_temp_dir();
let state =
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
let resp = prune_worktrees_route(
State(state),
Json(PruneWorktreesRequest {
dry_run: true,
discard_dirty: false,
merged_prs: false,
invoking_session: None,
project_root: Some("/nonexistent/8782/project".into()),
only_orphan_paths: None,
only_merged_paths: None,
only_discard_paths: None,
}),
)
.await
.into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn prune_route_rejects_an_unparseable_invoking_session() {
let root = crate::test_support::hermetic_temp_dir();
let state =
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
let bad = prune_managed_route(
State(state.clone()),
Json(PruneRequest {
state: "unresolvable".into(),
dry_run: true,
include_active: false,
invoking_session: Some("not-a-uuid".into()),
}),
)
.await
.into_response();
assert_eq!(
bad.status(),
StatusCode::BAD_REQUEST,
"a malformed self-exclusion must stop the prune, not be dropped"
);
let ok = prune_managed_route(
State(state),
Json(PruneRequest {
state: "unresolvable".into(),
dry_run: true,
include_active: false,
invoking_session: None,
}),
)
.await
.into_response();
assert_eq!(ok.status(), StatusCode::OK);
}
#[tokio::test]
async fn ephemeral_route_dry_run_reports_without_tearing_down() {
let root = crate::test_support::hermetic_temp_dir();
let state =
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
let body = body_json(
decommission_ephemeral_route(
State(state),
axum::extract::Query(EphemeralQuery { dry_run: true }),
)
.await
.into_response(),
)
.await;
assert_eq!(
body.get("dry_run").and_then(serde_json::Value::as_bool),
Some(true),
"the route must echo the honoured preview flag: {body}"
);
assert_eq!(
body.get("decommissioned")
.and_then(serde_json::Value::as_u64),
Some(0),
"an empty store sweeps nothing: {body}"
);
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("route body must be readable");
serde_json::from_slice(&bytes).expect("route body must be JSON")
}
#[allow(clippy::await_holding_lock)]
#[serial_test::serial]
#[tokio::test]
async fn prune_spares_a_stopped_records_workspace() {
use crate::session_manager::record::ManagedSessionId;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::{ManagedSessionState, SessionRecord};
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("stopped-but-live");
GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
let wt_str = wt.to_string_lossy().into_owned();
let root = crate::test_support::hermetic_temp_dir();
let state =
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
let mgr = state.session_manager().await;
let control = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], true, DirtyWorktreePolicy::Skip, &[])
.await
.expect("control sweep must not error");
assert!(
control.removed.contains(&wt),
"CONTROL: {wt_str} must be reclaimable with an empty active set, \
otherwise this test proves nothing; removed={:?} owner_unknown={:?} \
skipped_dirty={:?}",
control.removed,
control.owner_unknown,
control.skipped_dirty
);
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"pinned by #4288".into(),
Some(wt.clone()),
None,
Some(wt.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session record");
let stopped = SessionRecord {
state: ManagedSessionState::Stopped,
..record
};
mgr.store
.write()
.await
.upsert(stopped)
.await
.expect("persist the Stopped record");
let _env = crate::core::trusty_tools_config::env_test_lock();
unsafe {
std::env::set_var(
crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV,
&fx.repos_root,
)
};
let preview = body_json(
prune_worktrees_route(
State(state.clone()),
Json(PruneWorktreesRequest {
dry_run: true,
discard_dirty: false,
merged_prs: false,
invoking_session: None,
project_root: None,
only_orphan_paths: None,
only_merged_paths: None,
only_discard_paths: None,
}),
)
.await
.into_response(),
)
.await;
unsafe { std::env::remove_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV) };
let strings = |v: &serde_json::Value, key: &str| -> Vec<String> {
v.get(key)
.and_then(|x| x.as_array())
.unwrap_or_else(|| panic!("route body must carry `{key}`: {v}"))
.iter()
.map(|s| {
s.as_str()
.unwrap_or_else(|| panic!("`{key}` must hold strings: {v}"))
.to_owned()
})
.collect()
};
let preview_paths = strings(&preview, "paths");
assert!(
!preview_paths.contains(&wt_str),
"a Stopped record's workspace must NOT be reclaimable; \
{wt_str} appeared in the dry-run set {preview_paths:?}"
);
let preview_unknown = strings(&preview, "owner_unknown_paths");
assert!(
!preview_unknown.contains(&wt_str),
"a spared workspace is never even a candidate, so it must not be \
reported as owner-unknown either; got {preview_unknown:?}"
);
}
}