use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use tracing::{debug, info, warn};
use super::decommission::is_session_worktree;
use super::manager::SessionManager;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const CONNECT_TIMEOUT: Duration = Duration::from_millis(750);
pub(super) fn disposable_workspace_index_id(
workspace_path: Option<&Path>,
workspace_owned: bool,
) -> Option<String> {
let ws = workspace_path?;
if !(workspace_owned || is_session_worktree(ws)) {
return None;
}
let root = trusty_common::resolve_project_root(ws);
let id = trusty_common::derive_index_id(&root);
if id.trim().is_empty() { None } else { Some(id) }
}
pub(super) async fn delete_search_index_best_effort(index_id: &str) {
let Some(base) = trusty_common::resolve_daemon_base_url("trusty-search") else {
debug!(
index_id,
"trusty-search daemon not discoverable; skipping index removal on decommission (#2033)"
);
return;
};
let client = match build_client() {
Ok(c) => c,
Err(e) => {
warn!("trusty-search index-delete client build failed: {e}");
return;
}
};
let url = format!("{base}/indexes/{index_id}");
match client.delete(&url).send().await {
Ok(resp) if resp.status().is_success() => {
info!(
index_id,
"decommission: removed trusty-search index (#2033)"
);
}
Ok(resp) => {
warn!(
index_id,
status = %resp.status(),
"decommission: trusty-search index delete returned non-2xx"
);
}
Err(e) => {
warn!(
index_id,
"decommission: trusty-search index delete failed: {e}"
);
}
}
}
fn build_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
}
#[derive(Debug, Deserialize)]
struct IndexDetailRow {
id: String,
root_path: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct IndexDetailsResponse {
#[serde(default)]
indexes: Vec<IndexDetailRow>,
}
#[derive(Debug, Deserialize, Default)]
struct IndexStatusRow {
#[serde(default)]
chunk_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct IndexSnapshot {
pub id: String,
pub root_path: PathBuf,
pub chunk_count: u64,
}
pub(super) fn is_orphan_index(
entry: &IndexSnapshot,
active_workspace_paths: &HashSet<PathBuf>,
) -> bool {
if !is_session_worktree(&entry.root_path) {
return false;
}
if active_workspace_paths.contains(&entry.root_path) {
return false;
}
let canonical =
std::fs::canonicalize(&entry.root_path).unwrap_or_else(|_| entry.root_path.clone());
if active_workspace_paths.contains(&canonical) {
return false;
}
entry.chunk_count == 0 || !entry.root_path.exists()
}
async fn fetch_index_details(
client: &reqwest::Client,
base: &str,
) -> anyhow::Result<Vec<(String, PathBuf)>> {
let url = format!("{base}/indexes?details=true");
let body: IndexDetailsResponse = client.get(&url).send().await?.json().await?;
Ok(body
.indexes
.into_iter()
.filter_map(|row| row.root_path.map(|rp| (row.id, PathBuf::from(rp))))
.collect())
}
async fn fetch_chunk_count(client: &reqwest::Client, base: &str, id: &str) -> u64 {
let url = format!("{base}/indexes/{id}/status");
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => resp
.json::<IndexStatusRow>()
.await
.map(|s| s.chunk_count)
.unwrap_or(0),
_ => 0,
}
}
impl SessionManager {
async fn active_workspace_path_set(&self) -> HashSet<PathBuf> {
let mut set = HashSet::new();
for p in self
.list()
.await
.into_iter()
.filter_map(|r| r.workspace_path)
{
if let Ok(canonical) = std::fs::canonicalize(&p) {
set.insert(canonical);
}
set.insert(p);
}
set
}
pub async fn sweep_orphaned_search_indexes(
&self,
dry_run: bool,
) -> anyhow::Result<Vec<String>> {
let Some(base) = trusty_common::resolve_daemon_base_url("trusty-search") else {
debug!("trusty-search daemon not discoverable; skipping index orphan sweep (#2033)");
return Ok(Vec::new());
};
let client = build_client()?;
let details = fetch_index_details(&client, &base).await?;
let active_workspace_paths = self.active_workspace_path_set().await;
let mut candidates = Vec::new();
for (id, root_path) in details {
if !is_session_worktree(&root_path) {
continue;
}
let chunk_count = fetch_chunk_count(&client, &base, &id).await;
let snapshot = IndexSnapshot {
id: id.clone(),
root_path,
chunk_count,
};
if is_orphan_index(&snapshot, &active_workspace_paths) {
candidates.push(id);
}
}
if dry_run {
for id in &candidates {
info!(index_id = %id, "search-index-gc (dry-run): would remove orphaned/0-chunk index");
}
return Ok(candidates);
}
let mut removed = Vec::new();
for id in candidates {
let url = format!("{base}/indexes/{id}");
match client.delete(&url).send().await {
Ok(resp) if resp.status().is_success() => {
info!(index_id = %id, "search-index-gc: removed orphaned/0-chunk index");
removed.push(id);
}
Ok(resp) => {
warn!(
index_id = %id,
status = %resp.status(),
"search-index-gc: delete returned non-2xx"
);
}
Err(e) => {
warn!(index_id = %id, "search-index-gc: delete failed: {e}");
}
}
}
Ok(removed)
}
pub async fn reap_orphaned_search_indexes(&self) -> anyhow::Result<Vec<String>> {
self.sweep_orphaned_search_indexes(false).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn worktree_path(root: &Path, leaf: &str) -> PathBuf {
root.join("owner")
.join("repo")
.join(".worktrees")
.join(leaf)
}
#[test]
fn disposable_index_id_none_when_no_workspace() {
assert_eq!(disposable_workspace_index_id(None, true), None);
assert_eq!(disposable_workspace_index_id(None, false), None);
}
#[test]
fn disposable_index_id_none_for_unowned_non_worktree() {
let path = Path::new("/Users/dev/my-real-project");
assert_eq!(disposable_workspace_index_id(Some(path), false), None);
}
#[test]
fn disposable_index_id_derives_from_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "my-feature-worktree");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), "gitdir: /somewhere/else").unwrap();
let id = disposable_workspace_index_id(Some(&wt), false);
assert_eq!(id.as_deref(), Some("my-feature-worktree"));
}
#[test]
fn disposable_index_id_derives_from_owned_clone_path() {
let tmp = tempfile::tempdir().unwrap();
let clone_root = tmp.path().join("owner").join("cloned-repo");
std::fs::create_dir_all(clone_root.join(".git")).unwrap();
let id = disposable_workspace_index_id(Some(&clone_root), true);
assert_eq!(id.as_deref(), Some("cloned-repo"));
}
#[test]
fn is_orphan_index_false_for_non_worktree_root() {
let entry = IndexSnapshot {
id: "persistent-project".into(),
root_path: PathBuf::from("/Users/dev/persistent-project"),
chunk_count: 0,
};
assert!(!is_orphan_index(&entry, &HashSet::new()));
}
#[test]
fn is_orphan_index_false_when_claimed_by_active_session() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "live-session");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "live-session".into(),
root_path: wt.clone(),
chunk_count: 0,
};
let active: HashSet<PathBuf> = [wt].into_iter().collect();
assert!(!is_orphan_index(&entry, &active));
}
#[test]
fn is_orphan_index_true_for_zero_chunk_unclaimed_worktree() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "stub-session");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "stub-session".into(),
root_path: wt,
chunk_count: 0,
};
assert!(is_orphan_index(&entry, &HashSet::new()));
}
#[test]
fn is_orphan_index_true_for_deleted_root_path() {
let entry = IndexSnapshot {
id: "gone-session".into(),
root_path: PathBuf::from("/nonexistent/owner/repo/.worktrees/gone-session"),
chunk_count: 500,
};
assert!(is_orphan_index(&entry, &HashSet::new()));
}
#[test]
fn is_orphan_index_false_for_populated_unclaimed_worktree_that_still_exists() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "maybe-orphaned");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "maybe-orphaned".into(),
root_path: wt,
chunk_count: 42,
};
assert!(!is_orphan_index(&entry, &HashSet::new()));
}
}