1use serde::{Deserialize, Serialize};
26
27use crate::incident::{
28 ControlState, IncidentEvent, IncidentEventType, IncidentReasonCode, IncidentSource,
29 SelectedMode,
30};
31use crate::stale_target_reap::{idle_minutes_from_hours, is_safe_reap_path};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ReclaimCategory {
39 StaleTargetDir,
41 StaleCargoHome,
43 LogRotation,
45 OperatorRequiredManual,
47}
48
49impl ReclaimCategory {
50 #[must_use]
52 pub fn as_str(self) -> &'static str {
53 match self {
54 ReclaimCategory::StaleTargetDir => "stale_target_dir",
55 ReclaimCategory::StaleCargoHome => "stale_cargo_home",
56 ReclaimCategory::LogRotation => "log_rotation",
57 ReclaimCategory::OperatorRequiredManual => "operator_required_manual",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ReclaimSkipReason {
66 ActiveBuild,
68 UnmanagedRoot,
70 UnsafePath,
72 NotStale,
74 OperatorRequired,
76}
77
78impl ReclaimSkipReason {
79 #[must_use]
81 pub fn as_str(self) -> &'static str {
82 match self {
83 ReclaimSkipReason::ActiveBuild => "active_build",
84 ReclaimSkipReason::UnmanagedRoot => "unmanaged_root",
85 ReclaimSkipReason::UnsafePath => "unsafe_path",
86 ReclaimSkipReason::NotStale => "not_stale",
87 ReclaimSkipReason::OperatorRequired => "operator_required",
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case", tag = "decision")]
95pub enum ReclaimDecision {
96 Reclaim,
98 Skip { reason: ReclaimSkipReason },
100}
101
102impl ReclaimDecision {
103 #[must_use]
105 pub fn is_reclaim(self) -> bool {
106 matches!(self, ReclaimDecision::Reclaim)
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ReclaimCandidate {
114 pub path: String,
116 pub category: ReclaimCategory,
118 pub bytes: u64,
120 pub inodes: u64,
122 pub idle_minutes: u64,
124 pub active_build: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ReclaimPolicy {
131 pub managed_roots: Vec<String>,
133 pub min_idle_minutes: u64,
135}
136
137impl ReclaimPolicy {
138 #[must_use]
141 pub fn new(managed_roots: Vec<String>, idle_hours: u32) -> Self {
142 Self {
143 managed_roots,
144 min_idle_minutes: idle_minutes_from_hours(idle_hours),
145 }
146 }
147
148 #[must_use]
150 pub fn is_within_managed_root(&self, path: &str) -> bool {
151 self.managed_roots.iter().any(|root| {
152 let root = root.trim_end_matches('/');
153 !root.is_empty() && (path == root || path.starts_with(&format!("{root}/")))
154 })
155 }
156}
157
158#[must_use]
165pub fn decide_candidate(candidate: &ReclaimCandidate, policy: &ReclaimPolicy) -> ReclaimDecision {
166 let skip = |reason| ReclaimDecision::Skip { reason };
167 if !is_safe_reap_path(&candidate.path) {
168 return skip(ReclaimSkipReason::UnsafePath);
169 }
170 if !policy.is_within_managed_root(&candidate.path) {
171 return skip(ReclaimSkipReason::UnmanagedRoot);
172 }
173 if candidate.active_build {
174 return skip(ReclaimSkipReason::ActiveBuild);
175 }
176 if candidate.category == ReclaimCategory::OperatorRequiredManual {
177 return skip(ReclaimSkipReason::OperatorRequired);
178 }
179 if candidate.idle_minutes < policy.min_idle_minutes {
180 return skip(ReclaimSkipReason::NotStale);
181 }
182 ReclaimDecision::Reclaim
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct PathReceipt {
188 pub path: String,
189 pub category: ReclaimCategory,
190 #[serde(flatten)]
191 pub decision: ReclaimDecision,
192 pub bytes: u64,
193 pub inodes: u64,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct ReclaimReceipt {
199 pub managed_roots: Vec<String>,
201 pub min_idle_minutes: u64,
203 pub paths: Vec<PathReceipt>,
205 pub planned_bytes: u64,
207 pub planned_inodes: u64,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub actual_bytes: Option<u64>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub actual_inodes: Option<u64>,
215}
216
217impl ReclaimReceipt {
218 #[must_use]
220 pub fn reclaimable_paths(&self) -> Vec<&str> {
221 self.paths
222 .iter()
223 .filter(|p| p.decision.is_reclaim())
224 .map(|p| p.path.as_str())
225 .collect()
226 }
227
228 #[must_use]
230 pub fn skipped_for(&self, reason: ReclaimSkipReason) -> Vec<&str> {
231 self.paths
232 .iter()
233 .filter(|p| p.decision == ReclaimDecision::Skip { reason })
234 .map(|p| p.path.as_str())
235 .collect()
236 }
237
238 #[must_use]
240 pub fn protected_active(&self) -> Vec<&str> {
241 self.skipped_for(ReclaimSkipReason::ActiveBuild)
242 }
243
244 pub fn finalize(&mut self, actual_bytes: u64, actual_inodes: u64) {
247 self.actual_bytes = Some(actual_bytes);
248 self.actual_inodes = Some(actual_inodes);
249 }
250
251 #[must_use]
255 pub fn to_incident_event(
256 &self,
257 project_id: impl Into<String>,
258 occurred_at_unix_ms: u64,
259 ) -> IncidentEvent {
260 let reclaimable = self.reclaimable_paths().len();
261 let protected = self.protected_active().len();
262 let unmanaged = self.skipped_for(ReclaimSkipReason::UnmanagedRoot).len();
263 let mut event = IncidentEvent::new(
264 IncidentEventType::WorkerLifecycle,
265 IncidentReasonCode::DiskFull,
266 IncidentSource::Daemon,
267 project_id,
268 "disk_reclaim",
269 SelectedMode::Local,
270 true,
271 occurred_at_unix_ms,
272 )
273 .with_control(ControlState {
274 target_dir_policy: Some("reclaim".to_string()),
275 ..ControlState::default()
276 })
277 .with_detail("planned_bytes", self.planned_bytes.to_string())
278 .with_detail("planned_inodes", self.planned_inodes.to_string())
279 .with_detail("reclaimable", reclaimable.to_string())
280 .with_detail("protected_active", protected.to_string())
281 .with_detail("skipped_unmanaged", unmanaged.to_string());
282 if let Some(actual) = self.actual_bytes {
283 event = event.with_detail("actual_bytes", actual.to_string());
284 }
285 if let Some(actual) = self.actual_inodes {
286 event = event.with_detail("actual_inodes", actual.to_string());
287 }
288 event
289 }
290
291 #[must_use]
293 pub fn render(&self) -> String {
294 format!(
295 "reclaim: {} path(s) reclaimable, {} planned bytes, {} protected (active), {} unmanaged-skipped",
296 self.reclaimable_paths().len(),
297 self.planned_bytes,
298 self.protected_active().len(),
299 self.skipped_for(ReclaimSkipReason::UnmanagedRoot).len(),
300 )
301 }
302}
303
304#[must_use]
307pub fn plan_reclaim(candidates: &[ReclaimCandidate], policy: &ReclaimPolicy) -> ReclaimReceipt {
308 let mut paths = Vec::with_capacity(candidates.len());
309 let mut planned_bytes = 0u64;
310 let mut planned_inodes = 0u64;
311 for candidate in candidates {
312 let decision = decide_candidate(candidate, policy);
313 if decision.is_reclaim() {
314 planned_bytes = planned_bytes.saturating_add(candidate.bytes);
315 planned_inodes = planned_inodes.saturating_add(candidate.inodes);
316 }
317 paths.push(PathReceipt {
318 path: candidate.path.clone(),
319 category: candidate.category,
320 decision,
321 bytes: candidate.bytes,
322 inodes: candidate.inodes,
323 });
324 }
325 ReclaimReceipt {
326 managed_roots: policy.managed_roots.clone(),
327 min_idle_minutes: policy.min_idle_minutes,
328 paths,
329 planned_bytes,
330 planned_inodes,
331 actual_bytes: None,
332 actual_inodes: None,
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn policy() -> ReclaimPolicy {
341 ReclaimPolicy::new(
343 vec!["/tmp/rch".to_string(), "/data/rch/pool".to_string()],
344 12,
345 )
346 }
347
348 fn candidate(path: &str, category: ReclaimCategory) -> ReclaimCandidate {
349 ReclaimCandidate {
350 path: path.to_string(),
351 category,
352 bytes: 1_000,
353 inodes: 10,
354 idle_minutes: 10_000, active_build: false,
356 }
357 }
358
359 #[test]
362 fn no_active_build_path_is_reclaimed_even_under_critical_pressure() {
363 let aggressive = ReclaimPolicy {
366 managed_roots: vec!["/tmp/rch".to_string()],
367 min_idle_minutes: 0,
368 };
369 let mut c = candidate(
370 "/tmp/rch/proj/.rch-target-w-job-1-2-0",
371 ReclaimCategory::StaleTargetDir,
372 );
373 c.active_build = true;
374 let receipt = plan_reclaim(&[c], &aggressive);
375 assert!(
376 receipt.reclaimable_paths().is_empty(),
377 "active build must not be reclaimed"
378 );
379 assert_eq!(receipt.protected_active().len(), 1);
380 assert_eq!(receipt.planned_bytes, 0);
381 }
382
383 #[test]
384 fn no_unmanaged_path_is_touched() {
385 let c = candidate("/home/user/project/target", ReclaimCategory::StaleTargetDir);
387 let receipt = plan_reclaim(&[c], &policy());
388 assert!(receipt.reclaimable_paths().is_empty());
389 assert_eq!(
390 receipt.skipped_for(ReclaimSkipReason::UnmanagedRoot).len(),
391 1
392 );
393 }
394
395 #[test]
396 fn unsafe_paths_are_rejected_before_anything_else() {
397 for bad in [
398 "/tmp/rch/../etc/passwd",
399 "/tmp/rch/$(whoami)",
400 "/", "relative/path",
402 ] {
403 let c = candidate(bad, ReclaimCategory::StaleTargetDir);
404 let receipt = plan_reclaim(&[c], &policy());
405 assert!(
406 receipt.reclaimable_paths().is_empty(),
407 "unsafe path reclaimed: {bad}"
408 );
409 assert_eq!(
410 receipt.skipped_for(ReclaimSkipReason::UnsafePath).len(),
411 1,
412 "expected unsafe-path skip for {bad}"
413 );
414 }
415 }
416
417 #[test]
420 fn distinguishes_reclaimable_categories_from_operator_required() {
421 let candidates = vec![
422 candidate(
423 "/tmp/rch/p/.rch-target-w-job-1-2-0",
424 ReclaimCategory::StaleTargetDir,
425 ),
426 candidate("/tmp/rch/cargo-home-stale", ReclaimCategory::StaleCargoHome),
427 candidate("/tmp/rch/logs/old", ReclaimCategory::LogRotation),
428 candidate(
429 "/tmp/rch/mystery-bigdir",
430 ReclaimCategory::OperatorRequiredManual,
431 ),
432 ];
433 let receipt = plan_reclaim(&candidates, &policy());
434 assert_eq!(receipt.reclaimable_paths().len(), 3);
436 assert_eq!(
437 receipt
438 .skipped_for(ReclaimSkipReason::OperatorRequired)
439 .len(),
440 1
441 );
442 assert_eq!(receipt.planned_bytes, 3_000);
443 assert_eq!(receipt.planned_inodes, 30);
444 }
445
446 #[test]
447 fn conservative_staleness_preserves_warm_caches() {
448 let mut c = candidate(
450 "/tmp/rch/p/.rch-target-w-job-1-2-0",
451 ReclaimCategory::StaleTargetDir,
452 );
453 c.idle_minutes = 5; let receipt = plan_reclaim(&[c], &policy());
455 assert!(receipt.reclaimable_paths().is_empty());
456 assert_eq!(receipt.skipped_for(ReclaimSkipReason::NotStale).len(), 1);
457 }
458
459 #[test]
460 fn idle_floor_is_applied_even_with_zero_hours_config() {
461 let p = ReclaimPolicy::new(vec!["/tmp/rch".to_string()], 0);
464 assert_eq!(p.min_idle_minutes, 60);
465 let mut c = candidate(
466 "/tmp/rch/p/.rch-target-w-job-1-2-0",
467 ReclaimCategory::StaleTargetDir,
468 );
469 c.idle_minutes = 30;
470 let receipt = plan_reclaim(&[c], &p);
471 assert!(receipt.reclaimable_paths().is_empty());
472 }
473
474 #[test]
477 fn planned_totals_count_only_reclaimable() {
478 let candidates = vec![
479 candidate(
480 "/tmp/rch/p/.rch-target-w-job-1-2-0",
481 ReclaimCategory::StaleTargetDir,
482 ), {
484 let mut a = candidate(
485 "/tmp/rch/active/.rch-target-w-job-9-9-0",
486 ReclaimCategory::StaleTargetDir,
487 );
488 a.active_build = true; a.bytes = 9_999;
490 a
491 },
492 candidate("/elsewhere/junk", ReclaimCategory::StaleTargetDir), ];
494 let receipt = plan_reclaim(&candidates, &policy());
495 assert_eq!(
496 receipt.planned_bytes, 1_000,
497 "only the one reclaimable path counts"
498 );
499 }
500
501 #[test]
502 fn finalize_records_actual_freed() {
503 let c = candidate(
504 "/tmp/rch/p/.rch-target-w-job-1-2-0",
505 ReclaimCategory::StaleTargetDir,
506 );
507 let mut receipt = plan_reclaim(&[c], &policy());
508 assert_eq!(receipt.actual_bytes, None);
509 receipt.finalize(950, 9);
510 assert_eq!(receipt.actual_bytes, Some(950));
511 assert_eq!(receipt.actual_inodes, Some(9));
512 }
513
514 #[test]
515 fn journal_event_is_diskfull_with_accounting() {
516 let candidates = vec![
517 candidate(
518 "/tmp/rch/p/.rch-target-w-job-1-2-0",
519 ReclaimCategory::StaleTargetDir,
520 ),
521 {
522 let mut a = candidate(
523 "/tmp/rch/active/.rch-target-w-job-9-9-0",
524 ReclaimCategory::StaleTargetDir,
525 );
526 a.active_build = true;
527 a
528 },
529 ];
530 let mut receipt = plan_reclaim(&candidates, &policy());
531 receipt.finalize(1_000, 10);
532 let event = receipt.to_incident_event("proj-1", 1_700_000_000_000);
533 assert_eq!(event.reason_code, IncidentReasonCode::DiskFull);
534 assert_eq!(event.event_type, IncidentEventType::WorkerLifecycle);
535 assert_eq!(
536 event.details.get("planned_bytes").map(String::as_str),
537 Some("1000")
538 );
539 assert_eq!(
540 event.details.get("protected_active").map(String::as_str),
541 Some("1")
542 );
543 assert_eq!(
544 event.details.get("actual_bytes").map(String::as_str),
545 Some("1000")
546 );
547 }
548
549 #[test]
552 fn receipt_serializes_with_stable_decision_tokens() {
553 let candidates = vec![
554 candidate(
555 "/tmp/rch/p/.rch-target-w-job-1-2-0",
556 ReclaimCategory::StaleTargetDir,
557 ),
558 candidate("/elsewhere/x", ReclaimCategory::LogRotation),
559 ];
560 let receipt = plan_reclaim(&candidates, &policy());
561 let value = serde_json::to_value(&receipt).unwrap();
562 assert_eq!(value["paths"][0]["decision"], "reclaim");
563 assert_eq!(value["paths"][0]["category"], "stale_target_dir");
564 assert_eq!(value["paths"][1]["decision"], "skip");
565 assert_eq!(value["paths"][1]["reason"], "unmanaged_root");
566 assert!(value.get("actual_bytes").is_none());
568 let back: ReclaimReceipt = serde_json::from_value(value).unwrap();
570 assert_eq!(back, receipt);
571 }
572
573 #[test]
574 fn managed_root_membership_is_boundary_safe() {
575 let p = policy();
576 assert!(p.is_within_managed_root("/tmp/rch"));
577 assert!(p.is_within_managed_root("/tmp/rch/sub/dir"));
578 assert!(!p.is_within_managed_root("/tmp/rch-evil/x"));
580 assert!(!p.is_within_managed_root("/tmp/rchx"));
581 assert!(!p.is_within_managed_root("/data/rch/poolside")); assert!(p.is_within_managed_root("/data/rch/pool/abc"));
583 }
584}