use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::core::trusty_tools_config::{self, TrustyToolsConfig};
use crate::daemon::state::DaemonState;
use crate::disk::size_index::DirSize;
use crate::disk::survey::GroupBy;
use crate::disk::survey_run::{self, DiskProbes, run};
use crate::session_manager::worktree_ownership::AgentWorktreeOwner;
use crate::session_manager::worktree_reclaim::{
BranchPrState, PrIndex, pr_state_for_branch_within,
};
use crate::session_manager::worktree_reclaim_gh::GH_TIMEOUT;
use crate::session_manager::worktree_registry::ScannedWorktree;
use crate::session_manager::worktree_safety::inspect_dirt;
pub const MAX_BUDGET_SECONDS: u64 = 55;
fn clamp_budget(requested: Option<u64>) -> (Option<u64>, bool) {
match requested {
Some(s) if s > MAX_BUDGET_SECONDS => (Some(MAX_BUDGET_SECONDS), true),
other => (other, false),
}
}
fn parse_group_by(group_by: Option<&str>) -> Result<GroupBy, String> {
match group_by {
None => Ok(GroupBy::None),
Some("session") => Ok(GroupBy::Session),
Some(other) => Err(format!(
"disk_survey: unknown `group_by` value `{other}`; the only supported value is `session`"
)),
}
}
pub async fn disk_survey(
state: &Arc<DaemonState>,
project: Option<&str>,
budget_seconds: Option<u64>,
group_by: Option<&str>,
) -> Result<Value, String> {
let group_by = parse_group_by(group_by)?;
let (budget_seconds, budget_clamped) = clamp_budget(budget_seconds);
let config = TrustyToolsConfig::load();
let repos_root = trusty_tools_config::workspace_root(&config);
let keep_list = trusty_tools_config::load_disk_keep_list();
let manager = state.session_manager().await;
let claims = manager.workspace_claims(None).await;
let index = state.disk_size_index();
let state_for_agents = Arc::clone(state);
let project = project.map(str::to_string);
let deadline = budget_seconds.map(|s| Instant::now() + Duration::from_secs(s));
let adopted = crate::project::adopted_anchors_under(state.framework_root());
tokio::task::spawn_blocking(move || {
let agent_state = |owner: &AgentWorktreeOwner| {
crate::daemon::services::agent_worktree_reap::delegation_state_for_agent(
&state_for_agents,
&owner.agent_id,
)
};
let indexes: RefCell<BTreeMap<PathBuf, PrIndex>> = RefCell::new(BTreeMap::new());
let pr_state = |scanned: &ScannedWorktree, left: Option<Duration>| -> BranchPrState {
pr_for(&indexes, scanned, left)
};
let measure = |path: &Path, budget: Option<Duration>| -> Option<DirSize> {
let mut index = index.lock();
survey_run::measure(&mut index, path, budget)
};
let probes = DiskProbes {
pr_state: &pr_state,
claims: &claims,
agent_state: &agent_state,
dirt: &inspect_dirt,
measure: &measure,
now: &survey_run::SYSTEM_CLOCK,
};
let survey = run(
&repos_root,
&keep_list,
&probes,
deadline,
project.as_deref(),
group_by,
&adopted,
);
let mut value = serde_json::to_value(survey)
.map_err(|e| format!("disk_survey: serialize error: {e}"))?;
if let Value::Object(map) = &mut value {
map.insert("budget_clamped".to_string(), Value::Bool(budget_clamped));
}
Ok(value)
})
.await
.map_err(|e| format!("disk_survey: the survey pass panicked: {e}"))?
}
fn pr_for(
indexes: &RefCell<BTreeMap<PathBuf, PrIndex>>,
scanned: &ScannedWorktree,
left: Option<Duration>,
) -> BranchPrState {
let until = left.map(|l| Instant::now() + l);
let mut cache = indexes.borrow_mut();
let index = cache
.entry(scanned.registry_root.clone())
.or_insert_with(|| PrIndex::from_gh_within(&scanned.registry_root, gh_budget(until)));
let pr = index.state_for(scanned.branch.as_deref());
if matches!(
pr,
BranchPrState::Unknown | BranchPrState::LookupFailed { .. }
) && !index.is_complete()
&& let Some(branch) = scanned.branch.as_deref()
{
let budget = gh_budget(until);
if !budget.is_zero() {
return pr_state_for_branch_within(&scanned.registry_root, branch, budget);
}
}
pr
}
fn gh_budget(until: Option<Instant>) -> Duration {
match until {
None => GH_TIMEOUT,
Some(d) => d
.checked_duration_since(Instant::now())
.unwrap_or(Duration::ZERO)
.min(GH_TIMEOUT),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gh_budget_never_exceeds_the_fixed_ceiling() {
let generous = Instant::now() + GH_TIMEOUT * 10;
assert_eq!(gh_budget(Some(generous)), GH_TIMEOUT);
assert_eq!(gh_budget(None), GH_TIMEOUT);
}
#[test]
fn gh_budget_is_zero_once_the_survey_deadline_has_passed() {
let spent = Instant::now() - Duration::from_secs(1);
assert_eq!(gh_budget(Some(spent)), Duration::ZERO);
}
#[test]
fn a_budget_past_the_bridge_timeout_is_clamped() {
assert_eq!(clamp_budget(Some(120)), (Some(MAX_BUDGET_SECONDS), true));
assert_eq!(
Duration::from_secs(MAX_BUDGET_SECONDS),
Duration::from_secs(55),
"the clamp the schema's `maximum` advertises"
);
}
#[test]
fn a_budget_inside_the_bridge_timeout_is_untouched() {
assert_eq!(clamp_budget(Some(30)), (Some(30), false));
assert_eq!(
clamp_budget(Some(MAX_BUDGET_SECONDS)),
(Some(MAX_BUDGET_SECONDS), false),
"the ceiling itself is not a clamp"
);
assert_eq!(clamp_budget(None), (None, false));
}
#[test]
fn group_by_session_is_the_one_accepted_value() {
assert_eq!(parse_group_by(None), Ok(GroupBy::None));
assert_eq!(parse_group_by(Some("session")), Ok(GroupBy::Session));
}
#[test]
fn an_unknown_group_by_is_rejected() {
let err = parse_group_by(Some("project")).expect_err("unknown values must not be accepted");
assert!(err.contains("project"), "{err}");
assert!(
err.contains("session"),
"the error must name what IS accepted: {err}"
);
}
#[test]
fn gh_budget_hands_back_the_time_that_is_actually_left() {
let tight = Instant::now() + Duration::from_millis(200);
let budget = gh_budget(Some(tight));
assert!(!budget.is_zero(), "{budget:?}");
assert!(budget <= Duration::from_millis(200), "{budget:?}");
}
}