From 46bffbe3e696269346a09726ca97de8dd7ddc2cb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 10 Jul 2026 20:08:42 +0000 Subject: [PATCH 1/2] fix(doctor): derive orphan stores from registry reconstruction --- src/doctor.rs | 37 +++++------ src/doctor/heal.rs | 3 +- src/doctor/tests.rs | 73 +++++++++++++++++++--- src/migrate/registry.rs | 133 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 32 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index 175bbed8..5b0a25e5 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -408,7 +408,7 @@ async fn check_stale_stores(dc: &mut DoctorCounters) { } } - check_orphan_store_manifests(dc, &project_paths); + check_orphan_store_manifests(dc, &gdb).await; check_stale_code_projects(dc, &gdb).await; if let Some(profile_root) = profile_root.as_deref() { let drift = registry_drift::registry_drift_findings(&gdb, profile_root).await; @@ -691,11 +691,14 @@ fn registry_profile_roots(profile_root: &Path) -> Vec { roots } -fn check_orphan_store_manifests(dc: &mut DoctorCounters, project_paths: &[String]) { +async fn check_orphan_store_manifests( + dc: &mut DoctorCounters, + global_db: &crate::global_db::GlobalDb, +) { let Some(profile_root) = crate::config::user_data_dir() else { return; }; - let (orphan_count, issues) = orphan_store_manifest_report(&profile_root, project_paths); + let (orphan_count, issues) = orphan_store_manifest_report(global_db, &profile_root).await; for issue in issues.iter().take(10) { dc.warn(&format!("Store manifest issue: {issue}")); } @@ -710,29 +713,17 @@ fn check_orphan_store_manifests(dc: &mut DoctorCounters, project_paths: &[String /// Counts profile store manifests with no matching registry row, plus any /// manifest scan issues. Shared between `doctor` and the post-update health /// pass. -pub(crate) fn orphan_store_manifest_report( +pub(crate) async fn orphan_store_manifest_report( + global_db: &crate::global_db::GlobalDb, profile_root: &Path, - project_paths: &[String], ) -> (usize, Vec) { - let registered: std::collections::HashSet = project_paths - .iter() - .map(|path| crate::global_db::GlobalDb::canonical_project_key(std::path::Path::new(path))) - .collect(); let report = crate::migrate::registry::scan_profile_store_manifests( profile_root, crate::tracedecay::current_timestamp(), ); - let mut orphan_count = 0; - let mut warnings = report.issues; - for plan in report.plans { - let key = crate::global_db::GlobalDb::canonical_project_key(&plan.project.project_root); - if registered.contains(&key) { - continue; - } + let mut warnings = report.issues.clone(); + for plan in &report.plans { match plan.status { - crate::migrate::registry::RegistryReconstructionStatus::Eligible => { - orphan_count += 1; - } crate::migrate::registry::RegistryReconstructionStatus::Blocked => { warnings.push(format!( "blocked store manifest '{}': {}", @@ -740,11 +731,15 @@ pub(crate) fn orphan_store_manifest_report( plan.status_reason.as_deref().unwrap_or("not eligible") )); } - crate::migrate::registry::RegistryReconstructionStatus::Stale + crate::migrate::registry::RegistryReconstructionStatus::Eligible + | crate::migrate::registry::RegistryReconstructionStatus::Stale | crate::migrate::registry::RegistryReconstructionStatus::Retired => {} } } - (orphan_count, warnings) + let diff = + crate::migrate::registry::diff_registry_reconstruction_report(global_db, &report).await; + warnings.extend(diff.issues); + (diff.missing_plans, warnings) } /// Reports git-metadata watcher health (design D3/D5). diff --git a/src/doctor/heal.rs b/src/doctor/heal.rs index cda3c80b..3c84b73d 100644 --- a/src/doctor/heal.rs +++ b/src/doctor/heal.rs @@ -347,9 +347,8 @@ async fn collect_remaining_findings( remaining_registry_drift_count: usize, ) -> (Vec, Vec) { let mut findings = Vec::new(); - let project_paths = global_db.list_project_paths().await; let (orphan_count, warnings) = - super::orphan_store_manifest_report(profile_root, &project_paths); + super::orphan_store_manifest_report(global_db, profile_root).await; if orphan_count > 0 { findings.push(format!( "{orphan_count} orphan profile store manifest(s) can reconstruct registry rows" diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index eba113d0..39408324 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -32,8 +32,8 @@ fn format_bytes_boundaries() { assert_eq!(format_bytes(1024 * 1024 * 1024 * 2), "2.0 GB"); } -#[test] -fn orphan_reporting_counts_only_eligible_and_surfaces_blocked_reasons() { +#[tokio::test] +async fn orphan_reporting_uses_complete_registry_rows_not_token_accounting() { let base = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); let dir = tempfile::Builder::new() .prefix("doctor-orphans-") @@ -44,6 +44,14 @@ fn orphan_reporting_counts_only_eligible_and_surfaces_blocked_reasons() { let blocked_root = dir.path().join("blocked-repo"); std::fs::create_dir_all(&eligible_root).unwrap(); std::fs::create_dir_all(&blocked_root).unwrap(); + for root in [&eligible_root, &blocked_root] { + let status = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(root) + .status() + .unwrap(); + assert!(status.success()); + } write_enrollment_marker( &eligible_root, &EnrollmentMarker { @@ -52,6 +60,7 @@ fn orphan_reporting_counts_only_eligible_and_surfaces_blocked_reasons() { }, ) .unwrap(); + write_repository_identity_marker(&eligible_root, "proj_eligible").unwrap(); for (project_id, project_root) in [ ("proj_eligible", &eligible_root), ("proj_blocked", &blocked_root), @@ -76,14 +85,60 @@ fn orphan_reporting_counts_only_eligible_and_surfaces_blocked_reasons() { .unwrap(); } - let (count, warnings) = orphan_store_manifest_report(&profile_root, &[]); - - assert_eq!(count, 1); - assert!( - warnings - .iter() - .any(|warning| warning.contains("no repository identity or enrollment marker")) + let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) + .await + .unwrap(); + let (count, warnings) = orphan_store_manifest_report(&db, &profile_root).await; + + assert_eq!(count, 1, "{warnings:?}"); + + let scan = crate::migrate::registry::scan_profile_store_manifests(&profile_root, 1_800_000_000); + let eligible = crate::migrate::registry::RegistryReconstructionReport { + plans: scan + .plans + .into_iter() + .filter(|plan| { + plan.status == crate::migrate::registry::RegistryReconstructionStatus::Eligible + }) + .collect(), + issues: Vec::new(), + }; + let applied = crate::migrate::registry::apply_registry_reconstruction_report(&db, &eligible) + .await + .unwrap(); + assert_eq!(applied.projects, 1); + assert_eq!( + orphan_store_manifest_report(&db, &profile_root).await.0, + 0, + "a complete reconstruction registry is healthy without a legacy projects.path row" ); + assert_eq!( + crate::migrate::registry::apply_registry_reconstruction_report(&db, &eligible) + .await + .unwrap(), + crate::migrate::registry::RegistryReconstructionApplyReport::default() + ); + + db.conn() + .execute( + "DELETE FROM store_artifacts WHERE store_id=?1", + libsql::params![eligible.plans[0].store.store_id.as_str()], + ) + .await + .unwrap(); + assert_eq!(orphan_store_manifest_report(&db, &profile_root).await.0, 1); + crate::migrate::registry::apply_registry_reconstruction_report(&db, &eligible) + .await + .unwrap(); + + db.conn() + .execute( + "DELETE FROM store_instances WHERE store_id=?1", + libsql::params![eligible.plans[0].store.store_id.as_str()], + ) + .await + .unwrap(); + assert_eq!(orphan_store_manifest_report(&db, &profile_root).await.0, 1); } #[test] diff --git a/src/migrate/registry.rs b/src/migrate/registry.rs index edcf2df5..574ea7c1 100644 --- a/src/migrate/registry.rs +++ b/src/migrate/registry.rs @@ -67,6 +67,139 @@ pub struct RegistryReconstructionApplyReport { pub artifacts: usize, } +/// Read-only view of eligible reconstruction plans that would insert at least +/// one registry row. This uses the same conflict preflight as apply. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RegistryReconstructionDiffReport { + pub missing_plans: usize, + pub issues: Vec, +} + +pub async fn diff_registry_reconstruction_report( + db: &GlobalDb, + report: &RegistryReconstructionReport, +) -> RegistryReconstructionDiffReport { + let eligible = RegistryReconstructionReport { + plans: report + .plans + .iter() + .filter(|plan| plan.status == RegistryReconstructionStatus::Eligible) + .cloned() + .collect(), + issues: Vec::new(), + }; + let mut diff = RegistryReconstructionDiffReport { + issues: preflight_registry_reconstruction(db.conn(), &eligible).await, + ..RegistryReconstructionDiffReport::default() + }; + if !diff.issues.is_empty() { + return diff; + } + + for plan in &eligible.plans { + match registry_plan_has_missing_rows(db.conn(), plan).await { + Ok(true) => diff.missing_plans += 1, + Ok(false) => {} + Err(issue) => diff.issues.push(issue), + } + } + diff +} + +async fn registry_plan_has_missing_rows( + conn: &Connection, + plan: &RegistryReconstructionPlan, +) -> std::result::Result { + let project = &plan.project; + let root = GlobalDb::canonical_project_key(&project.project_root); + if query_optional_text( + conn, + "SELECT canonical_root FROM code_projects WHERE project_id=?1", + params![project.project_id.as_str()], + ) + .await? + .as_deref() + != Some(root.as_str()) + { + return Ok(true); + } + for alias in &project.aliases { + if query_optional_text( + conn, + "SELECT project_id FROM project_aliases WHERE alias_path=?1", + params![GlobalDb::canonical_project_key(alias)], + ) + .await? + .as_deref() + != Some(project.project_id.as_str()) + { + return Ok(true); + } + } + + let store_identity = serde_json::to_string(&( + &plan.store.project_id, + &plan.store.store_kind, + &plan.store.storage_mode, + &plan.store.store_relpath, + &plan.store.manifest_relpath, + )) + .unwrap_or_default(); + if query_optional_text( + conn, + "SELECT json_array(project_id, store_kind, storage_mode, store_relpath, manifest_relpath) + FROM store_instances WHERE store_id=?1", + params![plan.store.store_id.as_str()], + ) + .await? + .as_deref() + != Some(store_identity.as_str()) + { + return Ok(true); + } + + for scope in &plan.graph_scopes { + let scope_identity = serde_json::to_string(&( + &scope.project_id, + &scope.store_id, + &scope.branch_name, + &scope.db_relpath, + &scope.parent_scope_id, + )) + .unwrap_or_default(); + if query_optional_text( + conn, + "SELECT json_array(project_id, store_id, branch_name, db_relpath, parent_scope_id) + FROM graph_scopes WHERE graph_scope_id=?1", + params![scope.graph_scope_id.as_str()], + ) + .await? + .as_deref() + != Some(scope_identity.as_str()) + { + return Ok(true); + } + } + for artifact in &plan.artifacts { + if query_optional_text( + conn, + "SELECT store_id FROM store_artifacts + WHERE store_id=?1 AND artifact_kind=?2 AND relpath=?3", + params![ + artifact.store_id.as_str(), + artifact.artifact_kind.as_str(), + artifact.relpath.as_str(), + ], + ) + .await? + .is_none() + { + return Ok(true); + } + } + Ok(false) +} + pub async fn apply_registry_reconstruction_report( db: &GlobalDb, report: &RegistryReconstructionReport, From de55e3760d03882912808fc863a8f4dcb7e56e64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 10 Jul 2026 20:24:18 +0000 Subject: [PATCH 2/2] docs(agents): require conventional commit subjects --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 499e7aa7..6c45a9bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,13 @@ cargo test-all - When orchestrating parallel agents, the lead dictates exact scoped edits, subagents execute, and the lead reviews diffs before any push. - Subagents should not invent scope beyond what the lead dictated. +## Git + +- Every non-merge commit subject must pass `scripts/check-conventional-commits.sh` before push. +- Use `: ` or `(): ` with one of: + `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. +- Keep the subject at 72 characters or fewer. Example: `fix(doctor): avoid false orphan warnings`. + ## Learned Workspace Facts - Parallel branch work uses git worktrees under `.worktrees/` in the repo root (for example `.worktrees/codex-cli-args-stdin`).