diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index eb3724c3..e07c5635 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -2105,6 +2105,53 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { } return Ok(()); } + // Current beside the uninit tombstone (an `init` that published Current + // but could not remove it) is unreadable until another `init`; report + // that recovery instead of failing like a read. + Err(error @ codegraph_store::StoreError::CurrentTombstoned { .. }) => { + let recovery = format!("codegraph init {}", project.display()); + if json_output { + let mut status = json!({ + "initialized": false, + "version": VERSION, + "projectPath": project, + "indexPath": index_root, + "lastIndexed": null, + "dbPath": db, + "dbExists": db_exists, + "dbSizeBytes": db_size, + "extractionStatus": "current", + "extractionStatusDetail": error.to_string(), + "recoveryCommand": recovery, + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, + "daemonPidPath": daemon_pid_path, + "daemonSocketPath": daemon_socket_path, + "daemonLogPath": daemon_log_path, + }); + if wal_size > 0 { + status["walSizeBytes"] = json!(wal_size); + } + print_json(&status)?; + } else { + println!("\nCodeGraph Status\n"); + println!("Project: {}\n", project.display()); + println!("State: current (blocked by the uninit tombstone)"); + println!("Index Statistics:"); + println!(" DB Size: {:.2} MB", db_size as f64 / 1024.0 / 1024.0); + print_wal_status(wal_size, db_size); + println!("\n DB Path: {}", db.display()); + println!( + " Daemon: {}", + if daemon_running { "running" } else { "stopped" } + ); + println!( + "\nRecovery: run `{recovery}` to rebuild the index; only `init` removes the uninit tombstone." + ); + } + return Ok(()); + } Err(error) => return Err(error.into()), }; if status_open.rebuilding { @@ -2170,9 +2217,15 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "daemonLogPath": daemon_log_path, }); if matches!(&extraction_status, ExtractionStatus::Building { .. }) { - status["recoveryCommand"] = - json!(format!("codegraph index --force {}", project.display())); - } else if owner_mismatch_recoverable { + status["recoveryCommand"] = json!(if uninit_tombstone_present(&resolved) { + format!("codegraph init {}", project.display()) + } else { + format!("codegraph index --force {}", project.display()) + }); + } else if owner_mismatch_recoverable + || (matches!(&extraction_status, ExtractionStatus::Outdated { .. }) + && uninit_tombstone_present(&resolved)) + { status["recoveryCommand"] = json!(format!("codegraph init {}", project.display())); } else if lockless_missing { status["recoveryCommand"] = @@ -2197,11 +2250,18 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { match &extraction_status { ExtractionStatus::Building { .. } => { println!("Index is not readable while the build is incomplete."); - println!( - "Recovery: run `codegraph index --force {}` to rebuild the interrupted index; `codegraph init {}` is also supported.", - project.display(), - project.display() - ); + if uninit_tombstone_present(&resolved) { + println!( + "Recovery: run `codegraph init {}` to rebuild the interrupted index; it still carries an uninit tombstone, which only `init` removes.", + project.display() + ); + } else { + println!( + "Recovery: run `codegraph index --force {}` to rebuild the interrupted index; `codegraph init {}` is also supported.", + project.display(), + project.display() + ); + } } ExtractionStatus::Corrupt { .. } if owner_mismatch_recoverable => { println!( @@ -2218,6 +2278,13 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { ExtractionStatus::Missing if lockless_missing => { println!("{extraction_status_detail}"); } + ExtractionStatus::Outdated { .. } if uninit_tombstone_present(&resolved) => { + println!("Index was built by an older extraction version."); + println!( + "Recovery: run `codegraph init {}` to rebuild it; it still carries an uninit tombstone, which only `init` removes.", + project.display() + ); + } ExtractionStatus::Outdated { .. } => { println!("Index was built by an older extraction version."); println!( @@ -4746,11 +4813,18 @@ fn cmd_unlock(path: Option) -> Result<()> { Store::extraction_status(&paths), ExtractionStatus::Building { .. } ) { - println!( - "Index state remains building; no rollback was performed. Run `codegraph index --force {}` to rebuild it (or `codegraph init {}`).", - project.display(), - project.display() - ); + if uninit_tombstone_present(&paths) { + println!( + "Index state remains building; no rollback was performed. Run `codegraph init {}` to rebuild it.", + project.display() + ); + } else { + println!( + "Index state remains building; no rollback was performed. Run `codegraph index --force {}` to rebuild it (or `codegraph init {}`).", + project.display(), + project.display() + ); + } } } Err(codegraph_store::IndexLeaseError::TimedOut { .. }) => { @@ -7475,6 +7549,17 @@ fn has_lifecycle_namespace(project: &Path) -> bool { Store::extraction_status(&paths) != codegraph_store::ExtractionStatus::Missing } +/// Whether the uninit tombstone may be present, in any form. Only an explicit +/// `init` rebuilds under it, so recovery guidance names `init` instead of +/// `index --force` while it is there, and when its presence cannot be told: +/// `init` recovers either way. +fn uninit_tombstone_present(paths: &codegraph_core::IndexPaths) -> bool { + !matches!( + fs::symlink_metadata(paths.tombstone()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) +} + /// Resolve a readable project and report lifecycle-specific recovery without /// authorizing mutation. Only an all-present-slot OwnerMismatch or a Missing /// existing root with no permanent lock names explicit init; mixed or other @@ -7493,6 +7578,11 @@ fn resolve_required_project(path: Option) -> Result { paths.current_root().display(), project.display() ), + ExtractionStatus::Building { built: _ } if uninit_tombstone_present(&paths) => bail!( + "CodeGraph index build was interrupted in {}; reads remain blocked to avoid false empty results. Run `codegraph init {}` to rebuild it: the index still carries an uninit tombstone, which only `init` removes.", + project.display(), + project.display() + ), ExtractionStatus::Building { built: _ } => bail!( "CodeGraph index build was interrupted in {}; reads remain blocked to avoid false empty results. Run `codegraph index --force {}` to rebuild it (or `codegraph init {}`).", project.display(), @@ -7517,6 +7607,11 @@ fn resolve_required_project(path: Option) -> Result { project.display() ) } + ExtractionStatus::Outdated { built } if uninit_tombstone_present(&paths) => bail!( + "CodeGraph index in {} is outdated (built with extraction version {built}) and still carries an uninit tombstone; run `codegraph init {}` to rebuild it", + project.display(), + project.display() + ), ExtractionStatus::Outdated { built } => bail!( "CodeGraph index in {} is outdated (built with extraction version {built}); run `codegraph index --force {}` to rebuild it", project.display(), diff --git a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs index 9a477221..99f9a354 100644 --- a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs +++ b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs @@ -2102,3 +2102,374 @@ fn sync_refuses_uninitialized_namespace_without_mutation() { let after = namespace_snapshot(paths.current_root()); assert_namespace_unchanged(&before, &after, "uninitialized"); } + +/// An older version's `uninit --force` leaves the residue this version's leaves +/// — an `uninitialized` slot, a tombstone, no database — except that the slot +/// carries the older extraction version. It stays reserved for an explicit +/// `init` all the same: `status` reports it uninitialized, a sync, a catch-up +/// sync and `index --force` refuse it without touching a byte, a repeated +/// `uninit --force` continues it at this version, and `init` brings the index +/// back. +#[test] +fn an_older_versions_uninit_stays_reserved_for_an_explicit_init() { + let dir = TestDir::new("refuse-older-uninit"); + let project = init_project("refuse-older-uninit", &dir); + let project_arg = project.to_str().unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + let uninit = cli(&["uninit", "--force", project_arg]); + assert!(uninit.ok, "setup: uninit failed: {}", uninit.stderr); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "uninitialized", + ); + assert!( + paths.tombstone().is_file(), + "setup: uninit leaves its tombstone" + ); + assert!( + !paths.current_db().exists(), + "setup: uninit removes the database" + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized, + "an older version's uninit is uninitialized, not outdated" + ); + + let status = cli(&["status", project_arg, "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!( + json["extractionStatus"], "uninitialized", + "{}", + status.stdout + ); + + let before = namespace_snapshot(paths.current_root()); + for command in [ + &["sync", project_arg][..], + &["index", "--force", project_arg][..], + ] { + let run = cli(command); + assert!( + !run.ok, + "`codegraph {}` must refuse an older uninit: stdout={}, stderr={}", + command[0], run.stdout, run.stderr + ); + assert_namespace_unchanged( + &before, + &namespace_snapshot(paths.current_root()), + command[0], + ); + } + codegraph_watch::sync_project_once(&project) + .expect_err("a catch-up sync must refuse an older uninit"); + assert_namespace_unchanged( + &before, + &namespace_snapshot(paths.current_root()), + "catch-up", + ); + + let again = cli(&["uninit", "--force", project_arg]); + assert!( + again.ok, + "a repeated `uninit --force` must continue an older uninit: {}", + again.stderr + ); + let classification = codegraph_store::index_state::classify(&paths); + assert_eq!(classification.status(), &ExtractionStatus::Uninitialized); + assert_eq!( + classification + .authoritative() + .map(|slot| slot.record.extraction_version), + Some(CURRENT_EXTRACTION_VERSION), + "the continued uninit republishes at this extraction version" + ); + assert!(paths.tombstone().is_file(), "the tombstone stays"); + + let init = cli(&["init", project_arg]); + assert!(init.ok, "init failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!( + !paths.tombstone().exists(), + "an explicit init removes the tombstone" + ); + let search = cli(&["search", "add", "-p", project_arg]); + assert!( + search.ok, + "the rebuilt index must answer: {}", + search.stderr + ); +} + +/// `uninit --force` removes an index an older version built without +/// rebuilding it first: it publishes `uninitialized` at this extraction +/// version, writes the tombstone and deletes the database, exactly as it does +/// for an index this version built. +#[test] +fn uninit_removes_an_index_an_older_version_built() { + let dir = TestDir::new("uninit-outdated"); + let project = init_project("uninit-outdated", &dir); + let project_arg = project.to_str().unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: CURRENT_EXTRACTION_VERSION - 1 + }, + "setup: the staged slot must classify Outdated" + ); + assert!( + paths.current_db().is_file(), + "setup: the index has a database" + ); + + let uninit = cli(&["uninit", "--force", project_arg]); + assert!( + uninit.ok, + "uninit must remove an outdated index: stdout={}, stderr={}", + uninit.stdout, uninit.stderr + ); + let classification = codegraph_store::index_state::classify(&paths); + assert_eq!(classification.status(), &ExtractionStatus::Uninitialized); + assert_eq!( + classification + .authoritative() + .map(|slot| slot.record.extraction_version), + Some(CURRENT_EXTRACTION_VERSION), + "uninit publishes at this extraction version" + ); + assert!(paths.tombstone().is_file(), "uninit writes its tombstone"); + for removed in [ + paths.current_db(), + paths.current_root().join("codegraph.db-wal"), + paths.current_root().join("codegraph.db-shm"), + ] { + assert!( + !removed.exists(), + "uninit must remove {}", + removed.display() + ); + } +} + +/// An `init` after an uninit that stops once it has published `building` +/// leaves that phase beside the tombstone. Only another `init` may rebuild it: +/// recovery guidance names `init`, and `index --force`, `sync` and a catch-up +/// sync refuse without touching a byte. Before, each of them rebuilt the index +/// under the tombstone, which every read then refused. +#[test] +fn an_interrupted_init_after_an_uninit_is_rebuilt_only_by_init() { + let dir = TestDir::new("tombstone-building"); + let project = init_project("tombstone-building", &dir); + let project_arg = project.to_str().unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + let uninit = cli(&["uninit", "--force", project_arg]); + assert!(uninit.ok, "setup: uninit failed: {}", uninit.stderr); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "building", + ); + assert!(matches!( + Store::extraction_status(&paths), + ExtractionStatus::Building { .. } + )); + assert!(paths.tombstone().is_file(), "setup: the tombstone remains"); + + let init_command = format!("codegraph init {}", project.display()); + let status = cli(&["status", project_arg, "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["extractionStatus"], "building", "{}", status.stdout); + assert_eq!( + json["recoveryCommand"], + init_command.as_str(), + "{}", + status.stdout + ); + let human = cli(&["status", project_arg]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&init_command) && !human.stdout.contains("index --force"), + "status must recommend init: {}", + human.stdout + ); + let read = cli(&["search", "add", "-p", project_arg]); + assert!(!read.ok, "a read must stay blocked"); + assert!( + read.stderr.contains(&init_command) && !read.stderr.contains("index --force"), + "the read error must recommend init: {}", + read.stderr + ); + + let before = namespace_snapshot(paths.current_root()); + for command in [ + &["index", "--force", project_arg][..], + &["sync", project_arg][..], + ] { + let run = cli(command); + assert!( + !run.ok, + "`codegraph {}` must refuse an index under the tombstone: stdout={}, stderr={}", + command[0], run.stdout, run.stderr + ); + assert!( + run.stderr.contains("tombstone"), + "`codegraph {}` must say why: {}", + command[0], + run.stderr + ); + assert_namespace_unchanged( + &before, + &namespace_snapshot(paths.current_root()), + command[0], + ); + } + codegraph_watch::sync_project_once(&project) + .expect_err("a catch-up sync must refuse an index under the tombstone"); + assert_namespace_unchanged( + &before, + &namespace_snapshot(paths.current_root()), + "catch-up", + ); + + let init = cli(&["init", project_arg]); + assert!(init.ok, "init failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!( + !paths.tombstone().exists(), + "an explicit init removes the tombstone" + ); + let search = cli(&["search", "add", "-p", project_arg]); + assert!( + search.ok, + "the rebuilt index must answer: {}", + search.stderr + ); +} + +/// An index an older version built that still carries the tombstone, which an +/// explicit `init` that published Current but could not remove it leaves, is +/// not a `sync` upgrade either: `status` names `init` rather than `sync`, the +/// sync refuses it untouched, and `init` rebuilds it. +#[test] +fn sync_refuses_an_older_index_under_the_tombstone() { + let dir = TestDir::new("tombstone-outdated"); + let project = init_project("tombstone-outdated", &dir); + let project_arg = project.to_str().unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + fs::write(paths.tombstone(), b"uninitialized\n").expect("stage the tombstone"); + + let init_command = format!("codegraph init {}", project.display()); + let status = cli(&["status", project_arg, "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["extractionStatus"], "outdated", "{}", status.stdout); + assert_eq!( + json["recoveryCommand"], + init_command.as_str(), + "{}", + status.stdout + ); + let human = cli(&["status", project_arg]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&init_command) && !human.stdout.contains("codegraph sync"), + "status must recommend init, not sync: {}", + human.stdout + ); + + let before = namespace_snapshot(paths.current_root()); + let run = cli(&["sync", project_arg]); + assert!( + !run.ok && run.stderr.contains("tombstone"), + "sync must refuse an older index under the tombstone: stdout={}, stderr={}", + run.stdout, + run.stderr + ); + assert_namespace_unchanged(&before, &namespace_snapshot(paths.current_root()), "sync"); + + let init = cli(&["init", project_arg]); + assert!(init.ok, "init failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists()); +} + +/// A Current index beside the tombstone, which an `init` that published +/// Current but could not remove the tombstone leaves, is unreadable until +/// another `init`. `status` reports it with that recovery instead of failing +/// like a read, and the read error names it too. +#[test] +fn status_reports_a_current_index_under_the_tombstone() { + let dir = TestDir::new("tombstone-current"); + let project = init_project("tombstone-current", &dir); + let project_arg = project.to_str().unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + fs::write(paths.tombstone(), b"uninitialized\n").expect("stage the tombstone"); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + + let init_command = format!("codegraph init {}", project.display()); + let status = cli(&["status", project_arg, "--json"]); + assert!( + status.ok, + "status must report the tombstone, not fail: stdout={}, stderr={}", + status.stdout, status.stderr + ); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["initialized"], false, "{}", status.stdout); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + assert!( + json["extractionStatusDetail"] + .as_str() + .is_some_and(|detail| detail.contains("tombstone")), + "{}", + status.stdout + ); + assert_eq!( + json["recoveryCommand"], + init_command.as_str(), + "{}", + status.stdout + ); + let human = cli(&["status", project_arg]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains("blocked by the uninit tombstone") + && human.stdout.contains(&init_command), + "{}", + human.stdout + ); + let read = cli(&["search", "add", "-p", project_arg]); + assert!( + !read.ok && read.stderr.contains("codegraph init"), + "the read error must name init: {}", + read.stderr + ); + + let init = cli(&["init", project_arg]); + assert!(init.ok, "init failed: {}", init.stderr); + assert!(!paths.tombstone().exists()); + let status = cli(&["status", project_arg, "--json"]); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["initialized"], true, "{}", status.stdout); +} diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index b0218e61..39186e7b 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -105,9 +105,10 @@ fn a_cold_mcp_session_upgrades_a_v0_54_index_through_its_daemon() { } /// A daemon never undoes an older `uninit --force`. That leaves an -/// `uninitialized` slot at the older extraction version, which the state gate -/// classifies as `outdated`, a tombstone, and no database; the daemon must -/// leave all of it alone and refuse to start, as it did before. +/// `uninitialized` slot at the older extraction version, a tombstone, and no +/// database, which the state gate classifies as `uninitialized`, like this +/// version's own uninit; the daemon must not try to upgrade it, must leave all +/// of it alone, and must refuse to start. #[cfg(unix)] #[test] fn a_cold_mcp_session_leaves_an_older_uninit_alone() { @@ -126,10 +127,8 @@ fn a_cold_mcp_session_leaves_an_older_uninit_alone() { V0_54_EXTRACTION_VERSION, "uninitialized", ); - let outdated = ExtractionStatus::Outdated { - built: V0_54_EXTRACTION_VERSION, - }; - assert_eq!(Store::extraction_status(&paths), outdated); + let uninitialized = ExtractionStatus::Uninitialized; + assert_eq!(Store::extraction_status(&paths), uninitialized); assert!(paths.tombstone().is_file(), "uninit leaves its tombstone"); assert!(!paths.current_db().exists(), "uninit removes the database"); let slots = state_slot_bytes(&paths); @@ -146,7 +145,7 @@ fn a_cold_mcp_session_leaves_an_older_uninit_alone() { "the daemon recreated the database an uninit removed:\n{log}" ); assert!(paths.tombstone().is_file(), "the tombstone must stay"); - assert_eq!(Store::extraction_status(&paths), outdated); + assert_eq!(Store::extraction_status(&paths), uninitialized); assert_eq!( state_slot_bytes(&paths), slots, @@ -157,8 +156,12 @@ fn a_cold_mcp_session_leaves_an_older_uninit_alone() { "no daemon may serve this index" ); assert!( - log.contains("left the outdated index to the startup gate"), - "the daemon must say why it left the index alone:\n{log}" + log.contains("index state is uninitialized"), + "the daemon must refuse the uninit as uninitialized:\n{log}" + ); + assert!( + !log.contains("upgrading an index an older codegraph built"), + "the daemon must not try to upgrade an uninit:\n{log}" ); session.close(); } diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index c429d454..72cabfac 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -202,7 +202,9 @@ pub enum StoreError { status: ExtractionStatus, path: PathBuf, }, - #[error("Current index state is blocked by the uninitialized tombstone at {path}")] + #[error( + "Current index state is blocked by the uninitialized tombstone at {path}; run `codegraph init` to rebuild the index" + )] CurrentTombstoned { path: PathBuf }, #[error("Current index state has no SQLite database at {path}")] CurrentDatabaseMissing { path: PathBuf }, @@ -589,6 +591,7 @@ impl Store { ExtractionStatus::Current | ExtractionStatus::Building { .. } | ExtractionStatus::Uninitialized + | ExtractionStatus::Outdated { .. } ) => { if status == ExtractionStatus::Current { diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index 4e52b43c..1b3a0aaa 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -94,8 +94,11 @@ //! 6. Future storage protocol ⇒ [`ExtractionStatus::Future`]. Otherwise, //! `extractionVersion` above [`CURRENT_EXTRACTION_VERSION`] ⇒ //! [`ExtractionStatus::Future`] for EVERY phase, `uninitialized` included. -//! Below ⇒ [`ExtractionStatus::Outdated`] preserving the built version. Equal -//! ⇒ [`ExtractionStatus::Current`], [`ExtractionStatus::Building`], or +//! Below ⇒ [`ExtractionStatus::Outdated`] preserving the built version, except +//! the `uninitialized` phase, which is [`ExtractionStatus::Uninitialized`] +//! whichever older version wrote it: an uninit leaves no index to upgrade, and +//! its residue stays reserved for an explicit `init`. Equal ⇒ +//! [`ExtractionStatus::Current`], [`ExtractionStatus::Building`], or //! [`ExtractionStatus::Uninitialized`] by phase. //! //! Callers must branch on the typed variants and on [`CorruptReason`]; the @@ -482,7 +485,8 @@ pub enum ExtractionStatus { /// The extraction version the interrupted build was producing. built: u64, }, - /// A `uninit --force` was in progress when the namespace last advanced. + /// A `uninit --force` was in progress when the namespace last advanced, at + /// this extraction version or an older one. Uninitialized, /// No state slot exists at all. Missing, @@ -961,7 +965,15 @@ fn classify_raw( // recoverable interrupted uninit. ExtractionStatus::Future { built } } else if built < CURRENT_EXTRACTION_VERSION { - ExtractionStatus::Outdated { built } + // An older version's uninit leaves the residue this version's leaves: + // no index to upgrade, and only an explicit `init` may rebuild it. + // Reading it as Outdated would let a sync or reindex recreate the index + // under its tombstone. + if authoritative.record.phase == Some(StatePhase::Uninitialized) { + ExtractionStatus::Uninitialized + } else { + ExtractionStatus::Outdated { built } + } } else { match authoritative.record.phase { Some(StatePhase::Current) => ExtractionStatus::Current, diff --git a/crates/codegraph-store/src/index_state_publisher.rs b/crates/codegraph-store/src/index_state_publisher.rs index 9680386a..24513ff9 100644 --- a/crates/codegraph-store/src/index_state_publisher.rs +++ b/crates/codegraph-store/src/index_state_publisher.rs @@ -442,7 +442,10 @@ fn validate_transition( let allowed = matches!( (current, requested), (ExtractionStatus::Missing, StatePhase::Building) - | (ExtractionStatus::Outdated { .. }, StatePhase::Building) + | ( + ExtractionStatus::Outdated { .. }, + StatePhase::Building | StatePhase::Uninitialized + ) | ( ExtractionStatus::Uninitialized, StatePhase::Building | StatePhase::Uninitialized @@ -696,7 +699,10 @@ mod tests { let expected = matches!( (¤t, requested), (ExtractionStatus::Missing, StatePhase::Building) - | (ExtractionStatus::Outdated { .. }, StatePhase::Building) + | ( + ExtractionStatus::Outdated { .. }, + StatePhase::Building | StatePhase::Uninitialized + ) | ( ExtractionStatus::Uninitialized, StatePhase::Building | StatePhase::Uninitialized @@ -730,6 +736,7 @@ mod tests { fn every_publication_checkpoint_preserves_old_authority_or_exposes_valid_successor() { let allowed = [ (PriorStatus::Outdated, StatePhase::Building), + (PriorStatus::Outdated, StatePhase::Uninitialized), (PriorStatus::Uninitialized, StatePhase::Building), (PriorStatus::Uninitialized, StatePhase::Uninitialized), (PriorStatus::Current, StatePhase::Building), diff --git a/crates/codegraph-store/src/rebuild.rs b/crates/codegraph-store/src/rebuild.rs index 8ee35e2f..7cd86b5c 100644 --- a/crates/codegraph-store/src/rebuild.rs +++ b/crates/codegraph-store/src/rebuild.rs @@ -15,7 +15,10 @@ //! //! Only after every one of those steps succeeded may the rebuild atomically //! publish `phase=current`; it then removes a tombstone only for an explicit, -//! successful `init`. Any earlier fault leaves the namespace `Building` (or +//! successful `init`. Because nothing else removes it, every other rebuild (a +//! reindex, the migration a sync escalates to) refuses a namespace holding the +//! tombstone before step 3: it would publish Current under a tombstone, which no +//! read accepts. Any earlier fault leaves the namespace `Building` (or //! `Missing`, before the first publication), fail-closed and unreadable. The //! handle's `Drop` is emergency best-effort only: it can never publish `Current`, //! because no publication call exists on that path. @@ -39,7 +42,8 @@ use crate::{ /// Which lifecycle command is performing the destructive rebuild. /// /// The distinction is narrow and load-bearing: only an explicit, fully -/// successful `init` may remove an existing tombstone. +/// successful `init` may remove an existing tombstone, so only an explicit +/// `init` may rebuild while one exists. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RebuildKind { /// `codegraph init` — an explicit initialization/recovery rebuild. @@ -171,6 +175,23 @@ pub enum RebuildError { /// Resolved current root. path: PathBuf, }, + /// A rebuild other than an explicit init reached a namespace that still + /// holds the uninitialized tombstone, which only an explicit init removes. + #[error( + "index namespace {path} holds the uninitialized tombstone; only an explicit `init` may rebuild it" + )] + TombstoneRequiresExplicitInit { + /// Resolved current root. + path: PathBuf, + }, + /// The tombstone path could not be inspected under the lease. + #[error("cannot inspect the uninitialized tombstone {path}: {source}")] + InspectTombstone { + /// Tombstone path. + path: PathBuf, + /// Operating-system error. + source: io::Error, + }, /// The tombstone could not be removed after a successful explicit init. #[error("cannot remove the uninitialized tombstone {path}: {source}")] RemoveTombstone { @@ -280,10 +301,29 @@ fn begin_full_rebuild_with( path: paths.current_root().to_path_buf(), }); } + if kind != RebuildKind::ExplicitInit { + refuse_tombstoned_namespace(paths, &lease)?; + } begin_from_authorization(paths, lease, kind, authorization, fault) } +/// Refuse a rebuild other than an explicit `init` while the uninit tombstone +/// exists, in any form: only an explicit `init` removes it, so any other +/// rebuild would publish Current under it, which no read accepts. Runs under +/// the retained exclusive lease, before `phase=building` is published. +fn refuse_tombstoned_namespace(paths: &IndexPaths, lease: &IndexLease) -> Result<(), RebuildError> { + lease.validate_exclusive(paths)?; + let path = paths.tombstone(); + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(RebuildError::TombstoneRequiresExplicitInit { + path: paths.current_root().to_path_buf(), + }), + Err(source) => Err(RebuildError::InspectTombstone { path, source }), + } +} + /// Normalize only the copied/moved-project shape: every PRESENT fixed slot must /// independently be an [`CorruptReason::OwnerMismatch`]. A mixed namespace is /// genuine damage and remains untouched for the ordinary state gate to refuse. @@ -387,7 +427,7 @@ fn resume_full_rebuild_with( } } let lease = authorization.clone_lease(); - lease.validate_exclusive(paths)?; + refuse_tombstoned_namespace(paths, &lease)?; begin_from_authorization(paths, lease, RebuildKind::Reindex, authorization, fault) } @@ -1651,6 +1691,62 @@ mod tests { .expect("an explicit-init retry must recover Current+tombstone residue"); } + /// Only an explicit `init` may rebuild under the tombstone. An `init` that + /// follows an uninit and stops after publishing `building` leaves that + /// phase beside the tombstone. A reindex or the migration a sync escalates + /// to would publish Current under the tombstone, which no read accepts + /// until another `init`, so both refuse before publishing anything. + #[test] + fn only_an_explicit_init_rebuilds_under_the_tombstone() { + let project = TempProject::new("tombstone-building"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&paths); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire exclusive to stage the interrupted init"); + publish_index_state(&paths, &lease, StatePhase::Building) + .expect("the explicit init publishes building"); + drop(lease); + assert!(matches!( + Store::extraction_status(&paths), + ExtractionStatus::Building { .. } + )); + let slots = paths.state_slots().map(|slot| std::fs::read(slot).ok()); + + let error = begin_full_rebuild(&paths, RebuildKind::Reindex, deadline(), || false) + .expect_err("a reindex must refuse a namespace under the tombstone"); + assert!( + matches!(error, RebuildError::TombstoneRequiresExplicitInit { .. }), + "unexpected error: {error}" + ); + + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire exclusive for the sync gate"); + let StoreWriteOpen::FullRebuildRequired(authorization) = + Store::open_for_write(&paths, lease, StoreWritePurpose::IncrementalSync) + .expect("the sync gate escalates an interrupted build") + else { + panic!("the sync gate must escalate an interrupted build"); + }; + let error = resume_full_rebuild(&paths, authorization) + .expect_err("a sync migration must refuse a namespace under the tombstone"); + assert!( + matches!(error, RebuildError::TombstoneRequiresExplicitInit { .. }), + "unexpected error: {error}" + ); + + assert!(paths.tombstone().exists(), "the tombstone stays"); + assert_eq!( + paths.state_slots().map(|slot| std::fs::read(slot).ok()), + slots, + "a refused rebuild publishes nothing" + ); + + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists()); + } + #[test] fn rebuild_retains_one_exclusive_lease_and_never_reacquires() { let project = TempProject::new("lease"); diff --git a/crates/codegraph-store/src/uninit.rs b/crates/codegraph-store/src/uninit.rs index b4e09b22..4a845347 100644 --- a/crates/codegraph-store/src/uninit.rs +++ b/crates/codegraph-store/src/uninit.rs @@ -155,12 +155,15 @@ fn uninit_index_with( drain: impl FnOnce() -> Result<(), String>, fault: &mut impl UninitFault, ) -> Result { + // An index an older version built is removed as it is: uninit reads no + // row of it, so it need not be rebuilt first. let visible = Store::extraction_status(paths); if !matches!( visible, ExtractionStatus::Current | ExtractionStatus::Building { .. } | ExtractionStatus::Uninitialized + | ExtractionStatus::Outdated { .. } ) { return Err(UninitError::StateRejected { status: visible }); } @@ -178,6 +181,7 @@ fn uninit_index_with( ExtractionStatus::Current | ExtractionStatus::Building { .. } | ExtractionStatus::Uninitialized + | ExtractionStatus::Outdated { .. } ) { return Err(UninitError::StateRejected { diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index 343688c9..c83e09ea 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -179,10 +179,19 @@ fn index_state_current_protocol_maps_each_phase() { } #[test] -fn index_state_extraction_version_maps_outdated_and_future_for_every_phase() { +fn index_state_extraction_version_maps_outdated_and_future_by_phase() { let previous_extraction = CURRENT_EXTRACTION_VERSION - 1; let future_extraction = CURRENT_EXTRACTION_VERSION + 1; for phase in ["building", "current", "uninitialized"] { + // An older version's uninit leaves no index to upgrade: it is the + // residue this version's uninit leaves, reserved for an explicit init. + let older = |built| { + if phase == "uninitialized" { + ExtractionStatus::Uninitialized + } else { + ExtractionStatus::Outdated { built } + } + }; let old = TempTree::new("outdated"); write_wire( &old.slots()[0], @@ -192,10 +201,7 @@ fn index_state_extraction_version_maps_outdated_and_future_for_every_phase() { phase, OWNER, ); - assert_eq!( - classify_slots(&old.slots(), OWNER).status(), - &ExtractionStatus::Outdated { built: 1 } - ); + assert_eq!(classify_slots(&old.slots(), OWNER).status(), &older(1)); let previous = TempTree::new("previous-extraction"); write_wire( @@ -208,9 +214,7 @@ fn index_state_extraction_version_maps_outdated_and_future_for_every_phase() { ); assert_eq!( classify_slots(&previous.slots(), OWNER).status(), - &ExtractionStatus::Outdated { - built: previous_extraction - } + &older(previous_extraction) ); let future = TempTree::new("future-extraction"); diff --git a/crates/codegraph-store/tests/index_state_publisher.rs b/crates/codegraph-store/tests/index_state_publisher.rs index 721a6c5c..31af51f6 100644 --- a/crates/codegraph-store/tests/index_state_publisher.rs +++ b/crates/codegraph-store/tests/index_state_publisher.rs @@ -341,7 +341,6 @@ fn invalid_lifecycle_transitions_are_byte_nonmutating() { (PriorStatus::Missing, StatePhase::Current), (PriorStatus::Missing, StatePhase::Uninitialized), (PriorStatus::Outdated, StatePhase::Current), - (PriorStatus::Outdated, StatePhase::Uninitialized), (PriorStatus::Uninitialized, StatePhase::Current), (PriorStatus::Current, StatePhase::Current), ]; @@ -367,6 +366,36 @@ fn invalid_lifecycle_transitions_are_byte_nonmutating() { } } +/// `uninit --force` removes an index an older version built as it is, so an +/// outdated namespace may publish `uninitialized` directly, at this extraction +/// version, beside the older authority. +#[test] +fn an_outdated_namespace_may_be_uninitialized() { + let project = TempProject::new("outdated-uninit"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + stage_prior_status(&paths, &lease, PriorStatus::Outdated); + let older = std::fs::read(&paths.state_slots()[0]).unwrap(); + + publish_index_state(&paths, &lease, StatePhase::Uninitialized) + .expect("an outdated namespace may be uninitialized"); + + let classification = classify(&paths); + assert_eq!(classification.status(), &ExtractionStatus::Uninitialized); + let authority = classification.authoritative().expect("published authority"); + assert_eq!(authority.index, 1); + assert_eq!(authority.record.sequence, 5); + assert_eq!( + authority.record.extraction_version, + CURRENT_EXTRACTION_VERSION + ); + assert_eq!( + std::fs::read(&paths.state_slots()[0]).unwrap(), + older, + "the older authority stays as it was" + ); +} + #[test] fn shared_and_wrong_parent_capabilities_are_typed_and_byte_nonmutating() { let shared_project = TempProject::new("shared-capability"); diff --git a/crates/codegraph-store/tests/store_state_gates.rs b/crates/codegraph-store/tests/store_state_gates.rs index 20d6ab24..68951feb 100644 --- a/crates/codegraph-store/tests/store_state_gates.rs +++ b/crates/codegraph-store/tests/store_state_gates.rs @@ -861,7 +861,7 @@ fn write_purpose_matrix_is_typed_and_rejected_pairs_are_nonmutating() { ( FixtureStatus::Outdated, StoreWritePurpose::UninitContinuation, - "reject", + "uninit", ), ( FixtureStatus::Uninitialized, diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index 9819f37d..a24d197d 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -373,9 +373,10 @@ pub enum OutdatedUpgrade { /// Rebuild a complete index an older extraction version built, and nothing /// else: the upgrade a daemon runs before its startup gate. /// -/// The state gate classifies an older version's every phase as `Outdated`, and -/// `codegraph sync` migrates all of them. An automatic upgrade must not undo an -/// older `uninit --force` or recreate a database someone removed, so this takes +/// The state gate classifies an older version's `building` and `current` +/// phases as `Outdated` (its `uninitialized` one is `Uninitialized`), and +/// `codegraph sync` migrates both. An automatic upgrade must not undo an older +/// `uninit --force` or recreate a database someone removed, so this takes /// ONE exclusive lease, reclassifies under it, and requires an `Outdated` /// status whose authoritative slot records the `current` phase, no /// uninitialized tombstone, and the database file. Only then does it run the diff --git a/docs/cli.md b/docs/cli.md index c4b33830..9fb29bc5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -988,6 +988,12 @@ file/node/edge counts and `journalMode`. Human output identifies this as `current (blocked by SQLite sidecar)`. `status` never checkpoints, deletes, or otherwise heals the sidecar. +A `Current` index beside the uninit tombstone, which an `init` that published +`Current` but could not remove the tombstone leaves, is reported the same way, +with `recoveryCommand` set to `codegraph init `; human output shows +`current (blocked by the uninit tombstone)` and that recovery. Reads refuse such +an index until `init` rebuilds it. + When the WAL is larger than both the database and the configured WAL limit, `status` warns that live CodeGraph processes must be stopped before running: @@ -1356,7 +1362,12 @@ codegraph index --force /path/to/project # only when the CLI explicitly requires An index built by an older extraction version is a supported `sync` upgrade: `status` reports it as outdated, and `sync` rebuilds it from source into the current namespace, applying any pending schema migration on the way. The result -equals a fresh `init`. An MCP session upgrades a complete older index as well: +equals a fresh `init`. What an older version's `uninit --force` left is not an +index to upgrade: `status` reports it as uninitialized, as it does after this +version's own `uninit --force`, and `sync`, `index --force` and the daemon leave +it alone. Only `codegraph init` brings the index back, and `uninit --force` can +finish one that was interrupted. An MCP session upgrades a complete older index +as well: in the default daemon mode the project's daemon rebuilds it the same way before it starts serving, and with `CODEGRAPH_NO_DAEMON=1` the session's own catch-up does. Until the rebuild finishes, a tool call may wait for it, up to 30 seconds, or @@ -1364,10 +1375,11 @@ fail; a later call succeeds once it is done. If the rebuild fails, the daemon does not start. A failure before the rebuild begins replacing the index leaves the older index as it was; a later one can leave it `building`, as an interrupted `sync` does. The daemon also refuses to start on an older index that -is not complete (an interrupted build, an `uninit --force`, or a missing -database), and leaves it alone. Either way, run `codegraph status` for the state +is not complete (an interrupted build or a missing database), and leaves it +alone. Either way, run `codegraph status` for the state the index is in and the recovery it needs. Do not run `index --force` solely because the extraction version -changed. An older binary refuses an index a newer extraction version wrote and +changed. `uninit --force` removes an older index as it is, without upgrading it +first. An older binary refuses an index a newer extraction version wrote and leaves it untouched. If a supported grammar reports tree errors and extraction collapses to only the @@ -1384,6 +1396,17 @@ tracked file that turns into one leaves the index. Bytes that are not valid UTF-8 are decoded with U+FFFD replacements, so a Latin-1 or binary source file is indexed like any other instead of failing the run. +### After `uninit --force` + +`uninit --force` deletes the database and leaves `.codegraph/` holding its +state slots, its lock and an `uninitialized` tombstone, so nothing rebuilds the +index on its own: `status` reports `uninitialized`, and `sync`, `index --force`, +an MCP session and the daemon refuse it. `codegraph init` rebuilds it and then +removes the tombstone. Until that `init` finishes, every other rebuild refuses +the index, even one an interrupted `init` left `building`, because only `init` +removes the tombstone; `status` and the read errors name `codegraph init` as the +recovery then. + ### Index diagnostics `init`, `index`, and `sync` share `--debug` and `--debug-log `.