diff --git a/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs index 3391fe3c5d6..c33f3c452e2 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs @@ -664,12 +664,18 @@ fn git_sync_failure(message: &str, output: &Output) -> String { ) } -fn assert_mounted_git_sync(output: &Output, dry_run: bool) { +fn assert_mounted_git_sync(output: &Output, dry_run: bool, daemon_log: &Path) { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "sessions git-sync should succeed\nstdout:\n{stdout}\nstderr:\n{stderr}" + "sessions git-sync should succeed\nstdout:\n{stdout}\nstderr:\n{stderr}\ntopology diagnostics:\n{}", + std::fs::read_to_string(daemon_log) + .expect("read isolated daemon log") + .lines() + .filter(|line| line.contains("git_topology_publication_unavailable")) + .collect::>() + .join("\n") ); assert!( stdout.starts_with("session git sync completed (session-sync."), @@ -723,10 +729,13 @@ fn sessions_git_sync_on_a_cold_daemon_waits_for_the_project_mount() { let hold = canonical_temp_path(home.path()).join("hold-after-core-publish"); std::fs::write(&hold, b"hold").unwrap(); let entered = PathBuf::from(format!("{}.entered", hold.display())); - let _daemon = crate::common::spawn_tracedecay_daemon_with(home.path(), { + let daemon_log = home.path().join("git-sync-daemon.log"); + let _daemon = crate::common::spawn_tracedecay_daemon_logged(home.path(), &daemon_log, { let hold = hold.clone(); move |command| { - command.env("TRACEDECAY_TEST_HOLD_AFTER_CORE_PUBLISH", &hold); + command + .env("TRACEDECAY_TEST_HOLD_AFTER_CORE_PUBLISH", &hold) + .env("RUST_LOG", "warn"); } }); @@ -775,11 +784,16 @@ fn sessions_git_sync_on_a_cold_daemon_waits_for_the_project_mount() { assert_mounted_git_sync( &wait_for_git_sync(&mut child, &mut stdout, &mut stderr), true, + &daemon_log, ); let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["sessions", "git-sync"]); - assert_mounted_git_sync(&run_with_timeout(command, cli_timeout()), false); + assert_mounted_git_sync( + &run_with_timeout(command, cli_timeout()), + false, + &daemon_log, + ); } fn dashboard_session_authority(capabilities_url: &str) -> String { diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 7c6d4ead0c0..79396636d2a 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -576,6 +576,26 @@ fn floor_note(unavailable_entry_count: usize, truncated: bool) -> String { } } +/// Only an enrolled project has a first-party destructive reclaim path. +/// An unregistered directory or unrelated profile family must never be sent +/// through `projects forget`; it only accepts registered identities. +fn registered_project_reclaim_hint(entry: &ProfileFootprintEntryV1) -> Option { + if entry.family != ProfileFamilyV1::ProjectStore || entry.bytes <= entry.bound_bytes { + return None; + } + entry.project.as_ref()?.registered_root.as_ref()?; + let id = entry.key.strip_prefix("projects/").filter(|id| { + id.starts_with("proj_") + && id.len() > "proj_".len() + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + })?; + Some(format!( + "; if retention stalls, LAST RESORT (DELETES project data): `tracedecay --dry-run projects forget {id}` then `tracedecay --yes projects forget {id}`" + )) +} + fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { let label = match entry.family { ProfileFamilyV1::ProjectStore => "project store", @@ -609,10 +629,46 @@ fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { format_bytes(kinds.graph_replay_pool) ) }); + let reclaim = registered_project_reclaim_hint(entry).unwrap_or_default(); + let floor = floor_note(entry.unavailable_entry_count, false); + let render = |root: &str, reclaim: &str| { + format!("{label} {}{reclaim}{root}{breakdown}{floor}", entry.key) + }; + let fits = |subject: &str| { + let statement = footprint_statement(subject, entry.bytes, entry.bound_bytes); + super::diagnostics::bounded_statement(&statement) == statement + }; + let detailed = render(&root, &reclaim); + if reclaim.is_empty() || fits(&detailed) { + return detailed; + } + // Budget the complete rendered finding, including both commands, the + // deletion warning, and the measured overage. A root-length heuristic + // does not account for selector length, UTF-8, or the other evidence. + let root_state = entry + .project + .as_ref() + .map(|project| format!(" ({})", project.state.label())) + .unwrap_or_default(); + let compact = render( + &format!(" (registered root omitted from bounded diagnostic){root_state}"), + &reclaim, + ); + if fits(&compact) { + compact + } else { + // Never turn a long identity into a truncated destructive command. + // Preserve the root's liveness and measured pressure if the complete + // recovery command cannot fit within the diagnostic limit. + render(&root_state, "") + } +} + +fn footprint_statement(subject: &str, bytes: u64, bound_bytes: u64) -> String { format!( - "{label} {}{root}{breakdown}{}", - entry.key, - floor_note(entry.unavailable_entry_count, false) + "{subject}: {} of {} bound", + format_bytes(bytes), + format_bytes(bound_bytes) ) } @@ -639,11 +695,7 @@ fn bounded_finding( DoctorCoverageCompletenessV1::Complete, ) }; - let statement = format!( - "{subject}: {} of {} bound", - format_bytes(bytes), - format_bytes(bound_bytes) - ); + let statement = footprint_statement(subject, bytes, bound_bytes); let reference = DoctorEvidenceReferenceV1::new(key.to_owned()).ok()?; let evidence = DoctorEvidenceRefV1::new(DoctorFindingFamilyV1::Storage, reference); let coverage = DoctorCoverageStatementV1::new( @@ -997,6 +1049,202 @@ mod tests { ); } + #[test] + fn overbound_project_doctor_names_a_supported_destructive_reclaim_only_for_registered_stores() { + let fixture = profile_fixture(); + let overrides = BTreeMap::from([ + ("projects/proj_live".to_owned(), 1024), + ("projects/proj_stray".to_owned(), 1), + ]); + let mut over = census_profile_footprint(&fixture.profile, &fixture.registered, &overrides); + let over_findings = findings(&over); + let live = &over_findings["projects/proj_live"]; + assert_eq!(live.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = live.finding().coverage().statement(); + assert!( + statement.contains("tracedecay --dry-run projects forget proj_live"), + "{statement}" + ); + assert!( + statement.contains("tracedecay --yes projects forget proj_live"), + "{statement}" + ); + assert!(statement.contains("DELETES project data"), "{statement}"); + + let unregistered = &over_findings["projects/proj_stray"]; + assert_eq!( + unregistered.finding().state(), + DoctorEvidenceStateV1::Degraded + ); + assert!( + !unregistered + .finding() + .coverage() + .statement() + .contains("projects forget"), + "an unregistered directory has no safe project selector" + ); + assert!( + !over_findings["profile"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "a profile total does not identify a reclaimable project" + ); + + let long_root = "nested-repository/".repeat(70); + let registered = over + .entries + .iter_mut() + .find(|entry| entry.key == "projects/proj_live") + .expect("registered footprint"); + registered + .project + .as_mut() + .expect("registered project") + .registered_root = Some(long_root); + let bounded = findings(&over); + let bounded_statement = bounded["projects/proj_live"] + .finding() + .coverage() + .statement(); + assert!( + bounded_statement.contains("tracedecay --dry-run projects forget proj_live"), + "{bounded_statement}" + ); + assert!( + bounded_statement.contains(" of 1.0 KB bound"), + "over-budget bytes and limit must survive a long repository path: {bounded_statement}" + ); + assert!(bounded_statement.contains("registered root omitted")); + + over.entries + .iter_mut() + .find(|entry| entry.key == "projects/proj_live") + .expect("registered footprint") + .project + .as_mut() + .expect("registered project") + .state = ProjectRootStateV1::Missing; + let missing_statement = findings(&over)["projects/proj_live"] + .finding() + .coverage() + .statement() + .to_owned(); + assert!( + missing_statement.contains("(root missing on disk)"), + "a compact finding must retain a missing root: {missing_statement}" + ); + assert!( + missing_statement.contains("tracedecay --dry-run projects forget proj_live"), + "the bounded recovery command must remain intact: {missing_statement}" + ); + over.entries + .iter_mut() + .find(|entry| entry.key == "projects/proj_live") + .expect("registered footprint") + .project + .as_mut() + .expect("registered project") + .state = ProjectRootStateV1::Unverifiable; + let unverifiable_statement = findings(&over)["projects/proj_live"] + .finding() + .coverage() + .statement() + .to_owned(); + assert!( + unverifiable_statement.contains("(root unverifiable)"), + "a compact finding must retain an unverifiable root: {unverifiable_statement}" + ); + + let normal = + census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); + let healthy = findings(&normal); + assert_eq!( + healthy["projects/proj_live"].finding().state(), + DoctorEvidenceStateV1::HealthyCompleteCoverage + ); + assert!( + !healthy["projects/proj_live"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "within-budget stores must not be offered destructive recovery" + ); + } + + #[test] + fn reclaim_guidance_omits_unusable_project_selectors() { + let fixture = profile_fixture(); + for id in [ + "proj_".to_owned(), + "proj_bad;echo".to_owned(), + "proj_bad`id".to_owned(), + format!("proj_{}", "a".repeat(160)), + ] { + let key = format!("projects/{id}"); + let store = fixture.profile.join(&key).join("tracedecay.db"); + sized(&store, 2048); + let registered = BTreeMap::from([(id, fixture.live_root.clone())]); + let overrides = BTreeMap::from([(key.clone(), 1)]); + let footprint = census_profile_footprint(&fixture.profile, ®istered, &overrides); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + !statement.contains("projects forget"), + "unsafe or incomplete actions must be omitted: {statement}" + ); + assert!(statement.contains(" of 1 B bound"), "{statement}"); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 2048 + ); + } + } + + #[test] + fn reclaim_guidance_preserves_real_identity_commands_and_large_store_evidence() { + let temporary = tempfile::tempdir().expect("large footprint fixture"); + let profile = temporary.path().join("profile"); + let id = "proj_0123456789abcdef"; + let key = format!("projects/{id}"); + let store = profile.join(&key).join("tracedecay.db"); + // Sparse metadata-only input: no allocation or read of the 15 GiB body. + sized(&store, 15 * GIB); + let registered = BTreeMap::from([(id.to_owned(), "界/".repeat(100))]); + let footprint = census_profile_footprint(&profile, ®istered, &BTreeMap::new()); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + statement.contains(&format!("`tracedecay --dry-run projects forget {id}`")), + "{statement}" + ); + assert!( + statement.contains(&format!("`tracedecay --yes projects forget {id}`")), + "{statement}" + ); + assert!(statement.contains("DELETES project data"), "{statement}"); + assert!(statement.contains("registered root omitted"), "{statement}"); + assert!( + statement.ends_with(&format!( + "{} of {} bound", + format_bytes(15 * GIB), + format_bytes(default_bound_bytes(ProfileFamilyV1::ProjectStore)) + )), + "{statement}" + ); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 15 * GIB + ); + } + #[test] fn an_owner_override_replaces_the_default_bound() { let fixture = profile_fixture(); diff --git a/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs b/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs index 27ec0dd156a..dbf53305d3b 100644 --- a/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs +++ b/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs @@ -57,8 +57,10 @@ impl SessionSyncProjectContext { &self.project_id, ) { Ok(scope) => scope, - Err(_) => { - return GitTopologySyncOutcome::Finished(Err(GitTopologySyncFailure::Unavailable)); + Err(error) => { + return GitTopologySyncOutcome::Finished(Err( + unavailable("resolve project scope")(error), + )); } }; let Some(runtime) = project_sessions.project_graph_runtime() else { @@ -72,8 +74,10 @@ impl SessionSyncProjectContext { let runtime: Arc = Arc::new(runtime.clone()); let scope_sets = match project_sessions.authorized_scope_set_storage() { Ok(scope_sets) => scope_sets, - Err(_) => { - return GitTopologySyncOutcome::Finished(Err(GitTopologySyncFailure::Unavailable)); + Err(error) => { + return GitTopologySyncOutcome::Finished(Err(unavailable( + "open scope-set storage", + )(error))); } }; let project_root = self.project_root.clone(); @@ -97,7 +101,7 @@ impl SessionSyncProjectContext { result = &mut worker => { GitTopologySyncOutcome::Finished(match result { Ok(result) => result, - Err(_) => Err(GitTopologySyncFailure::Unavailable), + Err(error) => Err(unavailable("join topology publisher")(error)), }) } interruption = service.wait_for_interruption(request) => {