From 6fcc5f56cebe4e648f399c6b0d66e3fc947601a5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 00:02:03 +0000 Subject: [PATCH 1/2] fix: make doctor current-project check resolution-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor "Current project" check used the synchronous TraceDecay::is_initialized, which only looks for a repo-local .tracedecay/ index. On machines using profile-sharded stores it reported "No index — run `tracedecay init`" even though the project resolved to a fully indexed profile store, steering users into a redundant re-init. Resolve the store through the same registry/alias-aware path the real tools use (via the new TraceDecay::initialized_store_layout_with_options, shared with has_initialized_store), report the resolved store location, mode, and id, measure DB size at the resolved graph DB path, and only advise `tracedecay init` when resolution genuinely finds nothing. --- src/doctor.rs | 158 ++++++++++++++++++++++++++++++++++++++++++---- src/tracedecay.rs | 15 ++++- 2 files changed, 158 insertions(+), 15 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index 44b1e435..853e3c00 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -7,7 +7,8 @@ use std::path::{Component, Path, PathBuf}; use crate::agents::{self, DoctorCounters, HealthcheckContext}; use crate::display::{format_bytes, format_token_count}; -use crate::tracedecay::TraceDecay; +use crate::storage::StoreLayout; +use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; mod registry_drift; @@ -28,15 +29,24 @@ pub async fn run_doctor(agent_filter: Option<&str>) { eprintln!("\n\x1b[1mCurrent project\x1b[0m"); let project_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let data_dir = crate::config::get_tracedecay_dir(&project_path); - if TraceDecay::is_initialized(&project_path) { - dc.pass(&format!("Index found: {}/", data_dir.display())); - check_database(&mut dc, &project_path).await; - } else { - dc.warn(&format!( - "No index at {}/ — run `tracedecay init`", - data_dir.display() - )); + match resolve_current_project_store(&project_path, &TraceDecayOpenOptions::default()).await { + CurrentProjectStore::Resolved(layout) => { + dc.pass(&describe_resolved_store(&layout)); + check_database(&mut dc, &project_path, &layout.graph_db_path).await; + } + CurrentProjectStore::LegacyRepoLocal { db_path } => { + dc.pass(&format!( + "Index found: {}/ (legacy repo-local store)", + crate::config::get_tracedecay_dir(&project_path).display() + )); + check_database(&mut dc, &project_path, &db_path).await; + } + CurrentProjectStore::Uninitialized => { + dc.warn(&format!( + "No index found for {} — run `tracedecay init`", + project_path.display() + )); + } } check_global_db(&mut dc); @@ -71,10 +81,53 @@ pub async fn run_doctor(agent_filter: Option<&str>) { print_summary(&dc); } -/// Check database health: report size and run VACUUM to reclaim space. -async fn check_database(dc: &mut DoctorCounters, project_path: &Path) { +/// How the doctor "Current project" check sees the working directory's store. +#[derive(Debug)] +enum CurrentProjectStore { + /// A store resolved through the same registry/alias-aware path the tools + /// use (enrollment marker, git-common-dir alias, profile shard, …). + Resolved(Box), + /// No resolvable store, but an old repo-local `.tracedecay/` database exists. + LegacyRepoLocal { db_path: PathBuf }, + /// Resolution genuinely found nothing — `tracedecay init` is warranted. + Uninitialized, +} + +async fn resolve_current_project_store( + project_path: &Path, + open_options: &TraceDecayOpenOptions, +) -> CurrentProjectStore { + if let Some(layout) = + TraceDecay::initialized_store_layout_with_options(project_path, open_options).await + { + return CurrentProjectStore::Resolved(Box::new(layout)); + } let db_path = crate::config::get_project_db_path(project_path); - let size_before = std::fs::metadata(&db_path).map_or(0, |m| m.len()); + if db_path.is_file() { + return CurrentProjectStore::LegacyRepoLocal { db_path }; + } + CurrentProjectStore::Uninitialized +} + +fn describe_resolved_store(layout: &StoreLayout) -> String { + let mode = match layout.storage_mode { + crate::storage::StorageMode::ProjectLocal => "repo-local", + crate::storage::StorageMode::ProfileSharded => "profile-sharded", + }; + let store_id = layout + .identity + .project_id + .as_deref() + .map_or_else(String::new, |id| format!(", store {id}")); + format!( + "Index found: {}/ ({mode}{store_id})", + layout.data_root.display() + ) +} + +/// Check database health: report size and run VACUUM to reclaim space. +async fn check_database(dc: &mut DoctorCounters, project_path: &Path, db_path: &Path) { + let size_before = std::fs::metadata(db_path).map_or(0, |m| m.len()); let ts = match TraceDecay::open(project_path).await { Ok(ts) => ts, @@ -89,7 +142,7 @@ async fn check_database(dc: &mut DoctorCounters, project_path: &Path) { eprintln!(" Compacting database (VACUUM)…"); match ts.optimize().await { Ok(()) => { - let size_after = std::fs::metadata(&db_path).map_or(size_before, |m| m.len()); + let size_after = std::fs::metadata(db_path).map_or(size_before, |m| m.len()); if size_before > size_after { let reclaimed = size_before - size_after; dc.pass(&format!( @@ -643,6 +696,83 @@ mod tests { assert_eq!(format_bytes(1536), "1.5 KB"); } + #[tokio::test] + async fn current_project_store_resolves_profile_shard_via_registry_alias( + ) -> std::result::Result<(), Box> { + let dir = tempfile::TempDir::new()?; + let profile_root = dir.path().join("profile"); + let project_root = dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let project_root = canonical_temp_path(&project_root); + let shard_root = + crate::storage::profile_sharded_data_root(&profile_root, "proj_doctor_current"); + std::fs::create_dir_all(&shard_root)?; + std::fs::write( + shard_root.join(crate::config::db_filename(&shard_root)), + b"graph", + )?; + + let global_db_path = dir.path().join("global.db"); + let db = crate::global_db::GlobalDb::open_at(&global_db_path) + .await + .ok_or_else(|| std::io::Error::other("could not open global db"))?; + db.upsert_code_project( + "proj_doctor_current", + &project_root, + None, + None, + Some("main"), + ) + .await + .ok_or_else(|| std::io::Error::other("could not upsert project"))?; + db.upsert_store_instance(StoreInstanceUpsert { + store_id: "store:proj_doctor_current:profile_sharded".to_string(), + project_id: "proj_doctor_current".to_string(), + store_kind: "code_project".to_string(), + storage_mode: "profile_sharded".to_string(), + store_relpath: Path::new("projects") + .join("proj_doctor_current") + .to_string_lossy() + .to_string(), + manifest_relpath: Some(crate::storage::STORE_MANIFEST_FILENAME.to_string()), + last_verified_at: Some(1_800_000_000), + last_write_at: Some(1_800_000_000), + }) + .await + .ok_or_else(|| std::io::Error::other("could not upsert store"))?; + + let open_options = TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(global_db_path), + }; + + // No repo-local `.tracedecay/` index exists, yet the project must not + // be reported as uninitialized: resolution finds the profile shard. + assert!(!crate::config::has_project_database(&project_root)); + match resolve_current_project_store(&project_root, &open_options).await { + CurrentProjectStore::Resolved(layout) => { + assert_eq!(layout.data_root, shard_root); + assert_eq!( + layout.identity.project_id.as_deref(), + Some("proj_doctor_current") + ); + assert!(describe_resolved_store(&layout).contains("profile-sharded")); + } + other => panic!("expected resolved profile shard, got {other:?}"), + } + + // A project the registry knows nothing about should still get the + // `tracedecay init` advice. + let unregistered = dir.path().join("unregistered"); + std::fs::create_dir_all(&unregistered)?; + let unregistered = canonical_temp_path(&unregistered); + assert!(matches!( + resolve_current_project_store(&unregistered, &open_options).await, + CurrentProjectStore::Uninitialized + )); + Ok(()) + } + #[tokio::test] async fn registry_backed_profile_shard_is_not_stale_without_marker( ) -> std::result::Result<(), Box> { diff --git a/src/tracedecay.rs b/src/tracedecay.rs index 4fa45669..a05d63df 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -1163,9 +1163,22 @@ impl TraceDecay { project_root: &Path, open_options: &TraceDecayOpenOptions, ) -> bool { + Self::initialized_store_layout_with_options(project_root, open_options) + .await + .is_some() + } + + /// Resolves the store layout for a project using the same registry/alias + /// aware path as [`Self::has_initialized_store`], returning it only when + /// the resolved store's graph database actually exists. + pub async fn initialized_store_layout_with_options( + project_root: &Path, + open_options: &TraceDecayOpenOptions, + ) -> Option { Self::resolve_store_layout_for_local_identity(project_root, open_options) .await - .is_ok_and(|layout| layout.graph_db_path.is_file()) + .ok() + .filter(|layout| layout.graph_db_path.is_file()) } async fn resolve_store_layout_for_local_identity( From 2a61ccc280c06bc4106d0f1cc6c71d56c54e8c75 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 00:48:35 +0000 Subject: [PATCH 2/2] refactor: measure doctor DB size from the opened store's actual path check_database previously measured a caller-supplied db path that could differ from the DB TraceDecay::open actually serves (e.g. a branch- specific DB), making the VACUUM size report inaccurate. Derive the path from the opened instance instead, which also drops the threaded db_path parameter and the LegacyRepoLocal payload field. --- src/doctor.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index 853e3c00..7285ceda 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -32,14 +32,14 @@ pub async fn run_doctor(agent_filter: Option<&str>) { match resolve_current_project_store(&project_path, &TraceDecayOpenOptions::default()).await { CurrentProjectStore::Resolved(layout) => { dc.pass(&describe_resolved_store(&layout)); - check_database(&mut dc, &project_path, &layout.graph_db_path).await; + check_database(&mut dc, &project_path).await; } - CurrentProjectStore::LegacyRepoLocal { db_path } => { + CurrentProjectStore::LegacyRepoLocal => { dc.pass(&format!( "Index found: {}/ (legacy repo-local store)", crate::config::get_tracedecay_dir(&project_path).display() )); - check_database(&mut dc, &project_path, &db_path).await; + check_database(&mut dc, &project_path).await; } CurrentProjectStore::Uninitialized => { dc.warn(&format!( @@ -88,7 +88,7 @@ enum CurrentProjectStore { /// use (enrollment marker, git-common-dir alias, profile shard, …). Resolved(Box), /// No resolvable store, but an old repo-local `.tracedecay/` database exists. - LegacyRepoLocal { db_path: PathBuf }, + LegacyRepoLocal, /// Resolution genuinely found nothing — `tracedecay init` is warranted. Uninitialized, } @@ -102,9 +102,8 @@ async fn resolve_current_project_store( { return CurrentProjectStore::Resolved(Box::new(layout)); } - let db_path = crate::config::get_project_db_path(project_path); - if db_path.is_file() { - return CurrentProjectStore::LegacyRepoLocal { db_path }; + if crate::config::has_project_database(project_path) { + return CurrentProjectStore::LegacyRepoLocal; } CurrentProjectStore::Uninitialized } @@ -126,9 +125,10 @@ fn describe_resolved_store(layout: &StoreLayout) -> String { } /// Check database health: report size and run VACUUM to reclaim space. -async fn check_database(dc: &mut DoctorCounters, project_path: &Path, db_path: &Path) { - let size_before = std::fs::metadata(db_path).map_or(0, |m| m.len()); - +/// +/// The DB path is taken from the opened instance so the size measured is the +/// same file (possibly a branch-specific DB) that VACUUM actually compacts. +async fn check_database(dc: &mut DoctorCounters, project_path: &Path) { let ts = match TraceDecay::open(project_path).await { Ok(ts) => ts, Err(e) => { @@ -136,13 +136,15 @@ async fn check_database(dc: &mut DoctorCounters, project_path: &Path, db_path: & return; } }; + let db_path = ts.db_path(); + let size_before = std::fs::metadata(&db_path).map_or(0, |m| m.len()); dc.pass(&format!("DB size: {}", format_bytes(size_before))); eprintln!(" Compacting database (VACUUM)…"); match ts.optimize().await { Ok(()) => { - let size_after = std::fs::metadata(db_path).map_or(size_before, |m| m.len()); + let size_after = std::fs::metadata(&db_path).map_or(size_before, |m| m.len()); if size_before > size_after { let reclaimed = size_before - size_after; dc.pass(&format!(