diff --git a/crates/tracedecay-contracts/src/doctor/mod.rs b/crates/tracedecay-contracts/src/doctor/mod.rs index 8ce6d83106..5f11768f00 100644 --- a/crates/tracedecay-contracts/src/doctor/mod.rs +++ b/crates/tracedecay-contracts/src/doctor/mod.rs @@ -44,10 +44,10 @@ pub use sources::{ RemoteAuthorityReadV1, RemoteListenerReadV1, RemoteOperationalReadV1, ResidentMemoryDoctorPort, ResidentMemoryHolderReadV1, ResidentMemoryOwnerReadV1, ResidentMemoryReadV1, RuntimeHealthDoctorPort, RuntimeHealthReadV1, RuntimeLivenessV1, StorageDoctorPort, - advisory_feedback_findings, code_index_finding, configuration_finding, github_source_finding, - host_integration_finding, ingest_refusal_finding, language_server_finding, - observability_finding, operational_audit_findings, resident_memory_findings, - runtime_health_finding, + UnappliedConfigurationSettingV1, advisory_feedback_findings, code_index_finding, + configuration_finding, github_source_finding, host_integration_finding, ingest_refusal_finding, + language_server_finding, observability_finding, operational_audit_findings, + resident_memory_findings, runtime_health_finding, }; pub use types::{ DoctorCoverageCompletenessV1, DoctorCoverageStatementV1, DoctorEvidenceRefV1, diff --git a/crates/tracedecay-contracts/src/doctor/sources.rs b/crates/tracedecay-contracts/src/doctor/sources.rs index 489a0a8080..27e36a0a08 100644 --- a/crates/tracedecay-contracts/src/doctor/sources.rs +++ b/crates/tracedecay-contracts/src/doctor/sources.rs @@ -36,6 +36,7 @@ use std::pin::Pin; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use tracedecay_domain::configuration::SettingKey; use tracedecay_domain::{ CodeGenerationId, FeedbackCycleId, FeedbackCycleTerminationV1, FeedbackFindingId, FeedbackFindingLifecycleV1, FeedbackResultId, FeedbackScopeV1, ProviderEvaluationStateV1, @@ -43,6 +44,7 @@ use tracedecay_domain::{ }; use crate::RequestContext; +use crate::configuration::ConfigurationSettingFindingV1; use crate::error::ApplicationContractError; use crate::storage::findings::truncate_at_char_boundary; @@ -123,7 +125,7 @@ fn clean_finding( } /// The observed drift between desired and effective configuration. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConfigurationDriftV1 { /// Desired and effective configuration agree. @@ -132,6 +134,32 @@ pub enum ConfigurationDriftV1 { Drifted, /// A requested pin could not be honored by the authority. PinUnavailable, + /// The resolved configuration stores values the effective configuration + /// leaves unapplied. + Unapplied(Vec), +} + +/// One stored setting value the effective configuration does not apply. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct UnappliedConfigurationSettingV1 { + pub key: SettingKey, + pub finding: ConfigurationSettingFindingV1, +} + +fn unapplied_statement(settings: &[UnappliedConfigurationSettingV1]) -> String { + let statements = settings.iter().map(|setting| { + let key = setting.key.as_str(); + match &setting.finding { + ConfigurationSettingFindingV1::InvalidIndexPathPattern { + pattern, message, .. + } => format!( + "{key} stores pattern {pattern:?} that does not compile ({message}); indexing \ + runs without it. Set {key} to patterns that compile or unset it \ + (tracedecay_configuration_set / tracedecay_configuration_unset)" + ), + } + }); + bounded_statement(&statements.collect::>().join("; ")) } /// One configuration-authority resolve/pin health read. @@ -160,28 +188,32 @@ pub fn configuration_finding( ) -> Result { let family = DoctorFindingFamilyV1::Configuration; match read { - ConfigurationAuthorityReadV1::Resolved { drift, coverage } => match drift { - ConfigurationDriftV1::InSync => clean_finding( - family, - "configuration.resolved.in-sync", - *coverage, - "effective configuration matches the resolved authority", - ), - ConfigurationDriftV1::Drifted => source_finding( - family, - DoctorEvidenceStateV1::Degraded, - "configuration.resolved.drifted", - *coverage, - "effective configuration diverges from the desired authority", - ), - ConfigurationDriftV1::PinUnavailable => source_finding( - family, - DoctorEvidenceStateV1::Degraded, - "configuration.resolved.pin-unavailable", - *coverage, - "a requested configuration pin could not be honored", - ), - }, + ConfigurationAuthorityReadV1::Resolved { drift, coverage } => { + let (reference, statement) = match drift { + ConfigurationDriftV1::InSync => { + return clean_finding( + family, + "configuration.resolved.in-sync", + *coverage, + "effective configuration matches the resolved authority", + ); + } + ConfigurationDriftV1::Drifted => ( + "configuration.resolved.drifted", + Cow::Borrowed("effective configuration diverges from the desired authority"), + ), + ConfigurationDriftV1::PinUnavailable => ( + "configuration.resolved.pin-unavailable", + Cow::Borrowed("a requested configuration pin could not be honored"), + ), + ConfigurationDriftV1::Unapplied(settings) => ( + "configuration.resolved.unapplied", + Cow::Owned(unapplied_statement(settings)), + ), + }; + let state = DoctorEvidenceStateV1::Degraded; + source_finding(family, state, reference, *coverage, &statement) + } ConfigurationAuthorityReadV1::Unsupported => unobservable_finding( family, DoctorEvidenceStateV1::Unsupported, diff --git a/crates/tracedecay-daemon-service/src/doctor_kernel.rs b/crates/tracedecay-daemon-service/src/doctor_kernel.rs index 7cee1a602a..92a9388992 100644 --- a/crates/tracedecay-daemon-service/src/doctor_kernel.rs +++ b/crates/tracedecay-daemon-service/src/doctor_kernel.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tracedecay_application::advisory::github_runtime::github_source_status_v1; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; use tracedecay_code_index_runtime::code_index_scheduler::identity::repository_id_for; -use tracedecay_configuration::config::PinnedRuntimeConfiguration; +use tracedecay_configuration::config::{PinnedRuntimeConfiguration, setting_findings}; use tracedecay_contracts::doctor::{ AdvisoryFeedbackDoctorPort, AdvisoryFeedbackReadV1, CodeIndexMountDoctorPort, CodeIndexMountReadV1, CodeIndexMountStateV1, ConfigurationAuthorityDoctorPort, @@ -27,8 +27,8 @@ use tracedecay_contracts::doctor::{ OperationalAuditDoctorPort, OperationalAuditReadV1, ProfileAuthorityReadV1, RemoteOperationalReadV1, ResidentMemoryDoctorPort, ResidentMemoryHolderReadV1, ResidentMemoryOwnerReadV1, ResidentMemoryReadV1, RuntimeHealthDoctorPort, RuntimeHealthReadV1, - StorageDoctorPort, advisory_feedback_read_from_publication, merge_storage_reads, - runtime_health_read, storage_family_read, + StorageDoctorPort, UnappliedConfigurationSettingV1, advisory_feedback_read_from_publication, + merge_storage_reads, runtime_health_read, storage_family_read, }; use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_contracts::storage::SchemaConvergenceFindingV1; @@ -51,21 +51,38 @@ const DOCTOR_CONTEXT_HORIZON_MICROS: i64 = 30_000_000; /// Map a real pinned-configuration lookup outcome into a kernel read. /// -/// A pinned snapshot resolves in-sync (the cache invariant guarantees the pinned -/// configuration equals the value derived from its resolved snapshot, so within -/// the cache there is no unobserved drift). A cold cache, the fail-closed -/// accessor's `Err`, is a typed [`ConfigurationAuthorityReadV1::Absent`], never -/// a fabricated healthy result. +/// A pinned snapshot is in sync only when the runtime configuration applies +/// every stored value; each part [`setting_findings`] reports it leaves +/// unapplied makes the read [`ConfigurationDriftV1::Unapplied`]. A cold cache, +/// the fail-closed accessor's `Err`, is a typed +/// [`ConfigurationAuthorityReadV1::Absent`], never a fabricated healthy result. #[must_use] pub fn configuration_read_from_pin( resolved: &Result, ) -> ConfigurationAuthorityReadV1 { - match resolved { - Ok(_) => ConfigurationAuthorityReadV1::Resolved { - drift: ConfigurationDriftV1::InSync, - coverage: DoctorCoverageCompletenessV1::Complete, + let Ok(pinned) = resolved else { + return ConfigurationAuthorityReadV1::Absent; + }; + let unapplied: Vec<_> = pinned + .snapshot() + .effective_values + .iter() + .flat_map(|(key, value)| { + setting_findings(key, value).into_iter().map(|finding| { + UnappliedConfigurationSettingV1 { + key: key.clone(), + finding, + } + }) + }) + .collect(); + ConfigurationAuthorityReadV1::Resolved { + drift: if unapplied.is_empty() { + ConfigurationDriftV1::InSync + } else { + ConfigurationDriftV1::Unapplied(unapplied) }, - Err(_) => ConfigurationAuthorityReadV1::Absent, + coverage: DoctorCoverageCompletenessV1::Complete, } } diff --git a/crates/tracedecay-daemon-service/src/doctor_kernel/tests.rs b/crates/tracedecay-daemon-service/src/doctor_kernel/tests.rs index cb06c01f12..08851a863c 100644 --- a/crates/tracedecay-daemon-service/src/doctor_kernel/tests.rs +++ b/crates/tracedecay-daemon-service/src/doctor_kernel/tests.rs @@ -27,6 +27,88 @@ fn configuration_read_from_pin_absent_on_cold_cache() { ); } +fn pinned_index_exclude( + patterns: &[&str], +) -> tracedecay_configuration::config::PinnedRuntimeConfiguration { + use tracedecay_configuration::config::registry::ConfigurationRegistry; + use tracedecay_configuration::config::resolver::{ConfigurationLayerV1, resolve_configuration}; + use tracedecay_domain::configuration::{ + ConfigurationLayerIdV1, ConfigurationRevisionId, ConfigurationValueV1, + INDEX_EXCLUDE_SETTING_KEY, SettingKey, + }; + + let project_id = tracedecay_domain::ProjectId::new("project.doctor-unapplied-exclude").unwrap(); + let revision = ConfigurationRevisionId::new("configuration.revision.doctor-exclude").unwrap(); + let snapshot = resolve_configuration( + &ConfigurationRegistry::core().unwrap(), + &[ConfigurationLayerV1 { + layer: ConfigurationLayerIdV1::Project { + project_id: project_id.clone(), + }, + revision_id: revision.clone(), + entries: std::collections::BTreeMap::from([( + SettingKey::new(INDEX_EXCLUDE_SETTING_KEY).unwrap(), + ConfigurationValueV1::StringList( + patterns + .iter() + .map(|pattern| (*pattern).to_owned()) + .collect(), + ), + )]), + }], + ) + .expect("read-time validation admits a stored pattern") + .snapshot; + tracedecay_configuration::config::PinnedRuntimeConfiguration::new( + tracedecay_configuration::config::RuntimeConfigurationTarget { + profile_root: std::path::PathBuf::from("/profile"), + project_id, + project_root: std::path::PathBuf::from("/project"), + }, + revision, + snapshot, + ) + .expect("an unapplied pattern must not refuse the pin") +} + +/// A stored exclude pattern the pin skips is configuration out of sync with +/// what the operator stored, never "matches the resolved authority". +#[test] +fn configuration_finding_reports_a_stored_pattern_the_pin_leaves_unapplied() { + use tracedecay_contracts::doctor::{DoctorEvidenceStateV1, configuration_finding}; + + let finding = |patterns: &[&str]| { + let read = configuration_read_from_pin::<&str>(&Ok(pinned_index_exclude(patterns))); + let finding = configuration_finding(&read).expect("configuration finding"); + ( + finding.state(), + finding.evidence()[0].reference().as_str().to_owned(), + finding.coverage().statement().to_owned(), + ) + }; + + assert_eq!( + finding(&["src/[abc", "docs/**"]), + ( + DoctorEvidenceStateV1::Degraded, + "configuration.resolved.unapplied".to_owned(), + "index.exclude.v1 stores pattern \"src/[abc\" that does not compile (unclosed \ + character class; missing ']'); indexing runs without it. Set index.exclude.v1 to \ + patterns that compile or unset it (tracedecay_configuration_set / \ + tracedecay_configuration_unset)" + .to_owned(), + ) + ); + assert_eq!( + finding(&["docs/**"]), + ( + DoctorEvidenceStateV1::HealthyCompleteCoverage, + "configuration.resolved.in-sync".to_owned(), + "effective configuration matches the resolved authority".to_owned(), + ) + ); +} + /// The daemon-side Doctor reader must observe the exhaustive /// observation-authority invariant pass itself. Without a producer the signal /// is permanently not-run, which downgrades every `StorageRuntime` finding to diff --git a/crates/tracedecay/src/doctor.rs b/crates/tracedecay/src/doctor.rs index 6ef58016e1..14ea9b4a7c 100644 --- a/crates/tracedecay/src/doctor.rs +++ b/crates/tracedecay/src/doctor.rs @@ -11,10 +11,9 @@ use tracedecay_contracts::project_open::{ use tracedecay_contracts::storage::{ SchemaConvergenceFindingV1, SchemaConvergenceProgressV1, SchemaConvergenceStateV1, }; -use tracedecay_contracts::{ApplicationOutcome, ConfigurationSettingFindingV1, ResolvedSetting}; +use tracedecay_contracts::{ApplicationOutcome, ResolvedSetting}; use tracedecay_domain::configuration::{ - ConfigurationValueV1, INDEX_EXCLUDE_SETTING_KEY, INDEX_INCLUDE_SETTING_KEY, SettingKey, - USER_UPLOAD_ENABLED_SETTING_KEY, + ConfigurationValueV1, SettingKey, USER_UPLOAD_ENABLED_SETTING_KEY, }; use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingSurface}; @@ -156,9 +155,6 @@ pub async fn run_doctor( daemon_status.as_ref(), &mut pending_reset, )?; - if matches!(daemon_status, Some(Ok(Some(_)))) { - check_project_index_paths(&mut dc, profile, &project_path).await; - } check_watcher(&mut dc, profile); let upload_enabled = if daemon_listening { configured_upload_enabled(profile) @@ -1063,8 +1059,7 @@ fn check_automation_effect_resets( async fn configured_upload_enabled( profile: &tracedecay_runtime_core::config::ProfileRoot, ) -> tracedecay_domain::errors::Result { - // The setting belongs to the profile: the request names no project. - let setting = configured_setting(profile, None, USER_UPLOAD_ENABLED_SETTING_KEY).await?; + let setting = configured_setting(profile, USER_UPLOAD_ENABLED_SETTING_KEY).await?; match setting.effective_value { ConfigurationValueV1::Boolean(enabled) => Ok(enabled), _ => Err(tracedecay_domain::errors::TraceDecayError::Config { @@ -1073,41 +1068,8 @@ async fn configured_upload_enabled( } } -/// Reports every stored index path pattern the project's runtime -/// configuration leaves unapplied, with the command that repairs it. -#[hotpath::measure(label = "doctor.config.index_paths", future = true)] -async fn check_project_index_paths( - dc: &mut DoctorCounters, - profile: &tracedecay_runtime_core::config::ProfileRoot, - project_path: &Path, -) { - for key in [INDEX_EXCLUDE_SETTING_KEY, INDEX_INCLUDE_SETTING_KEY] { - match configured_setting(profile, Some(project_path), key).await { - Ok(setting) => { - for finding in setting.findings { - match finding { - ConfigurationSettingFindingV1::InvalidIndexPathPattern { - pattern, - message, - .. - } => dc.fail(&format!( - "{key} stores pattern {pattern:?} that does not compile ({message}); \ - indexing runs without it. Set {key} to patterns that compile or \ - unset it (tracedecay_configuration_set / tracedecay_configuration_unset)" - )), - } - } - } - // A reset-required or still-mounting store is already reported - // as its own state; a read that could not run is not an issue. - Err(error) => dc.warn(&format!("{key} could not be read: {error}")), - } - } -} - async fn configured_setting( profile: &tracedecay_runtime_core::config::ProfileRoot, - project_path: Option<&Path>, key: &str, ) -> tracedecay_domain::errors::Result { let operation = ApplicationSurfaceOperation::ConfigurationGet; @@ -1122,13 +1084,8 @@ async fn configured_setting( message: format!("could not create Doctor configuration request: {error}"), } })?; - let handshake = crate::daemon::handshake_for_current_client( - profile, - project_path.map(Path::to_path_buf), - None, - false, - false, - )?; + // The setting belongs to the profile: the request names no project. + let handshake = crate::daemon::handshake_for_current_client(profile, None, None, false, false)?; let client = crate::daemon::invocation_client_for_current(profile, handshake)?; let dispatched = resolve_application_surface_dispatch( BindingSurface::Cli,