From 527cd88663ac0a9a7913d4dfff52727fccc6b7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E9=B2=B2?= Date: Thu, 9 Jul 2026 14:58:24 +0800 Subject: [PATCH] feat(anolisa): add scoped status view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose scoped status through StateView so user mode can report the current user's records and readable system-scope records without merging their physical write targets. Each visible root keeps its own FsLayout and manifest catalog. Status uses the record's root when running integrity and manifest health probes, so system records are checked against system paths and system overlays. Keep mutation semantics unchanged. Status only reports visibility; state writes and receipts still belong to the current install mode's physical state. Doctor remains a follow-up for full scoped view adoption. Concrete system-root catalog loads ignore ANOLISA_DATA_DIR and exe-sibling discovery, so user-mode status cannot let user-controlled catalogs influence system-scope health results. Tests cover user-visible system records, shadowing, system-only projection, scope metadata, root-specific catalog selection, system-catalog env override protection, and RPM drift adjudication. Assisted-by: Codex:0.143.0 Signed-off-by: 爱鲲 --- .../crates/anolisa-cli/src/commands/common.rs | 94 ++- .../anolisa-cli/src/commands/state_view.rs | 2 + .../anolisa-cli/src/commands/tier1/doctor.rs | 5 + .../commands/tier1/list/tests/scoped_rows.rs | 10 + .../anolisa-cli/src/commands/tier1/status.rs | 743 ++++++++++++++---- 5 files changed, 702 insertions(+), 152 deletions(-) diff --git a/src/anolisa/crates/anolisa-cli/src/commands/common.rs b/src/anolisa/crates/anolisa-cli/src/commands/common.rs index 285979487..da1170f6b 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/common.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/common.rs @@ -282,13 +282,38 @@ fn validate_component_path_segment(component: &str, command: &str) -> Result<(), /// that the bundled layer is always-present and overlays stack on top. pub fn load_bundled_catalog(ctx: &CliContext, command: &str) -> Result { let layout = resolve_layout(ctx); - let bundled = packaged_manifests_root(&layout) + let bundled = discovered_packaged_manifests_root(&layout) .or_else(dev_tree_manifests) .unwrap_or_else(|| layout.datadir.join(MANIFESTS_SUBDIR)); + load_catalog_from_layers(&layout, ctx.install_mode, bundled, command) +} +/// Load the layered catalog for a concrete filesystem layout. +/// +/// Scoped read-only views may inspect records from a physical root that is +/// not the current write root. Callers pass the root's layout and scope so +/// bundled and overlay manifests are loaded from the same physical root as +/// the state record being projected. +pub fn load_catalog_for_layout( + layout: &FsLayout, + install_mode: InstallMode, + command: &str, +) -> Result { + let bundled = trusted_manifests_root(layout, install_mode) + .or_else(dev_tree_manifests) + .unwrap_or_else(|| layout.datadir.join(MANIFESTS_SUBDIR)); + load_catalog_from_layers(layout, install_mode, bundled, command) +} + +fn load_catalog_from_layers( + layout: &FsLayout, + install_mode: InstallMode, + bundled: PathBuf, + command: &str, +) -> Result { let overlay = layout.manifests_overlay.clone(); let overlay = overlay.is_dir().then_some(overlay); - let (system, user) = match ctx.install_mode { + let (system, user) = match install_mode { InstallMode::System => (overlay, None), InstallMode::User => (None, overlay), }; @@ -304,7 +329,25 @@ pub fn load_bundled_catalog(ctx: &CliContext, command: &str) -> Result Option { +fn trusted_manifests_root(layout: &FsLayout, install_mode: InstallMode) -> Option { + match install_mode { + InstallMode::System => trusted_system_manifests_root(layout), + InstallMode::User => discovered_packaged_manifests_root(layout), + } +} + +fn trusted_system_manifests_root(layout: &FsLayout) -> Option { + let local = layout.datadir.join(MANIFESTS_SUBDIR); + if local.is_dir() { + return Some(local); + } + layout + .package_datadir() + .map(|datadir| datadir.join(MANIFESTS_SUBDIR)) + .filter(|candidate| candidate.is_dir()) +} + +fn discovered_packaged_manifests_root(layout: &FsLayout) -> Option { // Discover the packaged datadir (`/share/anolisa/`) using // the shared probe in [`crate::packaged`] — that helper honors the // `ANOLISA_DATA_DIR` env override and binary-location lookup so a @@ -682,6 +725,51 @@ dest = "{datadir}/adapters/sec-core/openclaw/" ); } + #[test] + fn system_scoped_catalog_ignores_data_dir_env_override() { + let tmp = tempfile::tempdir().expect("tempdir"); + let layout = FsLayout::system(Some(tmp.path().join("system"))); + let env_datadir = tmp.path().join("env-datadir"); + let env_runtime = env_datadir.join("manifests/runtime"); + let system_runtime = layout.datadir.join("manifests/runtime"); + std::fs::create_dir_all(&env_runtime).expect("mkdir env runtime"); + std::fs::create_dir_all(&system_runtime).expect("mkdir system runtime"); + std::fs::write( + env_runtime.join("agentsight.toml"), + r#" +[component] +name = "agentsight" +version = "env" +"#, + ) + .expect("write env manifest"); + std::fs::write( + system_runtime.join("agentsight.toml"), + r#" +[component] +name = "agentsight" +version = "system" +"#, + ) + .expect("write system manifest"); + + let _guard = crate::packaged::DataDirEnvGuard::set(&env_datadir); + let catalog = + load_catalog_for_layout(&layout, InstallMode::System, "status").expect("catalog"); + + assert_eq!( + catalog.layers.bundled, + layout.datadir.join("manifests"), + "system-scoped catalog must use the system layout, not ANOLISA_DATA_DIR", + ); + assert_eq!( + catalog + .component("agentsight") + .map(|m| m.component.version.as_str()), + Some("system") + ); + } + /// `object_status_str` must cover every variant of `ObjectStatus` and /// produce the exact wire vocabulary the spec promises. If a new variant /// is added, this test forces us to extend the mapping. diff --git a/src/anolisa/crates/anolisa-cli/src/commands/state_view.rs b/src/anolisa/crates/anolisa-cli/src/commands/state_view.rs index 9c4df1d4b..0093b8566 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/state_view.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/state_view.rs @@ -32,6 +32,7 @@ pub(crate) enum StateVisibility { #[derive(Debug, Clone)] pub(crate) struct ScopedStateRoot { pub(crate) scope: StateScope, + pub(crate) layout: FsLayout, pub(crate) state_path: PathBuf, pub(crate) writable: bool, pub(crate) state: InstalledState, @@ -123,6 +124,7 @@ impl StateView { }; let root = ScopedStateRoot { scope: spec.scope, + layout, state_path, writable: spec.writable, state, diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/doctor.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/doctor.rs index 348fbdcdb..74942fb19 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/doctor.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/doctor.rs @@ -1469,6 +1469,11 @@ mod tests { ComponentRecord { name: name.to_string(), status: status.to_string(), + scope: "system".to_string(), + active: true, + mutable_by_current_invocation: true, + shadowed_by: None, + state_path: Some("/tmp/anolisa-system-state/installed.toml".to_string()), version: Some("1.0.0".to_string()), installed_at: None, last_operation_id: None, diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs index c4eaea1fb..79cffd061 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use anolisa_core::state::{InstalledState, ObjectStatus, Ownership}; +use anolisa_platform::fs_layout::FsLayout; use crate::commands::state_view::{ScopedStateRoot, StateScope, StateView}; use crate::commands::tier1::list::{ListArgs, build_rows_from_view}; @@ -62,12 +63,21 @@ fn user_record_shadows_system_record_in_list_rows() { fn user_plus_system_view(user_state: InstalledState, system_state: InstalledState) -> StateView { let user_root = ScopedStateRoot { scope: StateScope::User, + layout: FsLayout::user_with_overrides( + PathBuf::from("/tmp/anolisa-home"), + None, + None, + Some(PathBuf::from("/tmp/anolisa-user-state")), + None, + None, + ), state_path: PathBuf::from("/tmp/anolisa-user-state/installed.toml"), writable: true, state: user_state, }; let system_root = ScopedStateRoot { scope: StateScope::System, + layout: FsLayout::system(Some(PathBuf::from("/tmp/anolisa-system"))), state_path: PathBuf::from("/tmp/anolisa-system-state/installed.toml"), writable: false, state: system_state, diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs index 6ac8525c4..6edd65166 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs @@ -15,6 +15,7 @@ use chrono::{SecondsFormat, Utc}; use clap::Parser; use serde::Serialize; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -53,6 +54,7 @@ use anolisa_platform::rpm_query::RpmPackageQuery; use crate::color::{Palette, pad_right}; use crate::commands::common; use crate::commands::common::RepoPersistPolicy; +use crate::commands::state_view::{ScopedStateRoot, StateScope, StateView, StateVisibility}; use crate::commands::tier1::install::rpm_package_candidates_with_index; use crate::context::{CliContext, InstallMode}; use crate::repo_config::BackendConfig; @@ -93,6 +95,13 @@ pub(crate) struct AdapterSummaryRecord { pub(crate) struct ComponentRecord { pub(crate) name: String, pub(crate) status: String, + pub(crate) scope: String, + pub(crate) active: bool, + pub(crate) mutable_by_current_invocation: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) shadowed_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) state_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) version: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -127,24 +136,81 @@ pub(crate) struct ComponentRecord { pub(crate) provisioned_packages: Vec, } +#[derive(Serialize)] +struct StatusPayload { + components: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + warnings: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct ScopedCatalogs { + entries: Vec, +} + +#[derive(Debug)] +struct ScopedCatalog { + state_path: PathBuf, + catalog: Catalog, +} + +impl ScopedCatalogs { + fn load(view: &StateView, command: &str) -> Self { + let entries = view + .visible_roots + .iter() + .filter_map(|root| { + common::load_catalog_for_layout( + &root.layout, + install_mode_for_scope(root.scope), + command, + ) + .ok() + .map(|catalog| ScopedCatalog { + state_path: root.state_path.clone(), + catalog, + }) + }) + .collect(); + Self { entries } + } + + #[cfg(test)] + fn from_entries(entries: Vec<(PathBuf, Catalog)>) -> Self { + Self { + entries: entries + .into_iter() + .map(|(state_path, catalog)| ScopedCatalog { + state_path, + catalog, + }) + .collect(), + } + } + + fn catalog_for(&self, root: &ScopedStateRoot) -> Option<&Catalog> { + self.entries + .iter() + .find(|entry| entry.state_path == root.state_path) + .map(|entry| &entry.catalog) + } +} + pub fn handle(args: StatusArgs, ctx: &CliContext) -> Result<(), CliError> { - let mut state = common::load_installed_state(ctx, COMMAND)?; + let mut view = StateView::load(ctx, COMMAND, StateVisibility::UserPlusSystem)?; + migrate_view_states(&mut view); let layout = common::resolve_layout(ctx); - common::migrate_v3_symlinks(&mut state, &layout); - // Catalog is best-effort: if manifests are missing or malformed, status - // still reports state-on-disk plus the integrity probe. The manifest - // health checks layer is purely additive — never an error path that - // would mask a working install. - let catalog = common::load_bundled_catalog(ctx, COMMAND).ok(); - let install_mode = ctx.install_mode.as_str(); - + // Catalogs are best-effort: if manifests are missing or malformed for a + // scoped root, status still reports state-on-disk plus integrity. Manifest + // health checks are additive and must not mask a working install. + let catalogs = ScopedCatalogs::load(&view, COMMAND); let adapter_scan = common::build_adapter_manager(ctx).scan().ok(); let query = RpmPackageQuery::system(); let selected_component = args .component .as_deref() - .map(|target| common::lookup_component_name(target, &state, ctx, COMMAND)); + .map(|target| lookup_component_name_from_view(target, &view, ctx)); // repo_config / component_index are still needed for the observed-record // probe below (system mode only). Name resolution above is handled by @@ -160,13 +226,12 @@ pub fn handle(args: StatusArgs, ctx: &CliContext) -> Result<(), CliError> { .as_ref() .and_then(|cfg| load_optional_component_index(&layout, &env, cfg)); - let mut records = select_components( - &state, - &layout, - catalog.as_ref(), - install_mode, + let mut records = select_components_from_view( + &view, + &catalogs, selected_component.as_deref(), adapter_scan.as_ref().map(|r| r.entries.as_slice()), + Some(&query), ); // Read-only Observed report (§8): when a named component is absent from @@ -185,23 +250,50 @@ pub fn handle(args: StatusArgs, ctx: &CliContext) -> Result<(), CliError> { } } - // Drift adjudication (#960): compare in-state RPM components against rpmdb - // and override the wire status with `drifted` / `missing` on divergence. - // Like the observed report above this stays strictly read-only — it adjusts - // only the wire records, never `installed.toml`. - apply_rpm_drift(&mut records, &state, &query); - if ctx.json { - let data = serde_json::json!({ "components": records }); - return render_json(COMMAND, data); + return render_json( + COMMAND, + StatusPayload { + components: records, + warnings: view.warnings, + }, + ); } if !ctx.quiet { + render_warnings(&view.warnings); render_human(&records, ctx.verbose, ctx.no_color); } Ok(()) } +fn migrate_view_states(view: &mut StateView) { + for root in &mut view.visible_roots { + common::migrate_v3_symlinks(&mut root.state, &root.layout); + } + if let Some(root) = view.visible_roots.iter().find(|root| root.writable) { + view.writable = root.clone(); + } +} + +fn lookup_component_name_from_view(input: &str, view: &StateView, ctx: &CliContext) -> String { + if view + .visible_components() + .iter() + .any(|record| record.object.name == input) + { + return input.to_string(); + } + common::lookup_component_name(input, &view.writable.state, ctx, COMMAND) +} + +const fn install_mode_for_scope(scope: StateScope) -> InstallMode { + match scope { + StateScope::User => InstallMode::User, + StateScope::System => InstallMode::System, + } +} + /// Pure selector: project [`InstalledState`] down to component records, /// optionally filtered to a single name. Extracted so tests can exercise /// the filtering/synthetic-not-installed logic without mocking @@ -225,6 +317,7 @@ pub(crate) fn select_components( .iter() .map(|o| { let mut rec = record_from_object(layout, catalog, install_mode, o); + apply_current_scope(&mut rec, layout, install_mode); rec.adapters = adapter_summaries_for(&o.name, adapter_scan); rec }) @@ -232,27 +325,118 @@ pub(crate) fn select_components( Some(target) => match installed.iter().find(|o| o.name == target) { Some(obj) => { let mut rec = record_from_object(layout, catalog, install_mode, obj); + apply_current_scope(&mut rec, layout, install_mode); rec.adapters = adapter_summaries_for(&obj.name, adapter_scan); vec![rec] } - None => vec![ComponentRecord { - name: target.to_string(), - status: "not_installed".to_string(), - version: None, - installed_at: None, - last_operation_id: None, - enabled_features: Vec::new(), - health: Vec::new(), - adapters: Vec::new(), - rpm_package: None, - rpm_evr: None, - rpm_source_repo: None, - provisioned_packages: Vec::new(), - }], + None => vec![not_installed_record(target)], }, } } +pub(crate) fn select_components_from_view( + view: &StateView, + catalogs: &ScopedCatalogs, + name: Option<&str>, + adapter_scan: Option<&[ScanEntry]>, + rpm_query: Option<&dyn PackageQuery>, +) -> Vec { + let visible_components = view.visible_components(); + let selected: Vec<_> = match name { + None => visible_components + .iter() + .copied() + .filter(|record| record.active) + .collect(), + Some(target) => visible_components + .iter() + .copied() + .filter(|record| record.object.name == target) + .collect(), + }; + + let Some(target_records) = (!selected.is_empty()).then_some(selected) else { + return name + .map(|target| vec![not_installed_record(target)]) + .unwrap_or_default(); + }; + + let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + target_records + .into_iter() + .map(|record| { + let catalog = catalogs.catalog_for(record.root); + let mut component = record_from_object( + &record.root.layout, + catalog, + record.scope().label(), + record.object, + ); + component.adapters = adapter_summaries_for(&component.name, adapter_scan); + apply_record_scope(&mut component, &record); + if let Some(query) = rpm_query { + apply_rpm_drift_to_record( + &mut component, + record.object.rpm_metadata.as_ref(), + query, + &checked_at, + ); + } + component + }) + .collect() +} + +fn apply_record_scope( + component: &mut ComponentRecord, + record: &crate::commands::state_view::ScopedInstalledObject<'_>, +) { + component.scope = record.scope().label().to_string(); + component.active = record.active; + component.mutable_by_current_invocation = record.mutable_by_current_invocation; + component.shadowed_by = record + .shadowed_by + .map(StateScope::label) + .map(str::to_string); + component.state_path = Some(record.root.state_path.display().to_string()); +} + +fn apply_current_scope(component: &mut ComponentRecord, layout: &FsLayout, install_mode: &str) { + component.scope = install_mode.to_string(); + component.active = true; + component.mutable_by_current_invocation = true; + component.shadowed_by = None; + component.state_path = Some( + layout + .state_dir + .join("installed.toml") + .display() + .to_string(), + ); +} + +fn not_installed_record(name: &str) -> ComponentRecord { + ComponentRecord { + name: name.to_string(), + status: "not_installed".to_string(), + scope: "none".to_string(), + active: false, + mutable_by_current_invocation: false, + shadowed_by: None, + state_path: None, + version: None, + installed_at: None, + last_operation_id: None, + enabled_features: Vec::new(), + health: Vec::new(), + adapters: Vec::new(), + rpm_package: None, + rpm_evr: None, + rpm_source_repo: None, + provisioned_packages: Vec::new(), + } +} + /// Probe rpmdb for `component` and, if a matching system RPM is installed, /// build an `observed` record (§8). Returns `None` when nothing is installed /// or the host has no rpm tooling — the caller keeps the `not_installed` row. @@ -288,6 +472,11 @@ fn observed_record( return Some(ComponentRecord { name: target.component, status: "observed".to_string(), + scope: "system".to_string(), + active: false, + mutable_by_current_invocation: false, + shadowed_by: None, + state_path: None, version: Some(evr.clone()), installed_at: None, last_operation_id: None, @@ -361,66 +550,39 @@ pub(crate) fn probe_rpm_drift(meta: &RpmMetadata, query: &dyn PackageQuery) -> O } } -/// Layer live rpmdb drift onto the projected records (#960). -/// -/// For every record whose in-state object carries [`RpmMetadata`] *and* whose -/// live projection is still clean (`installed` / `adopted`), compare against -/// rpmdb and, on divergence, override the wire status with `drifted` / `missing` -/// plus a `rpm:drift` health entry. Surfacing a manual `dnf update` / `rpm -e` -/// is the point. -/// -/// The clean-status gate is deliberate: integrity / manifest health may already -/// have escalated an RPM object to `failed` / `degraded`, and a component may be -/// deliberately `disabled` — those carry a more-severe signal that a drift label -/// must not mask, so they are left untouched (the divergence still shows up in -/// `repair`). Objects without RPM metadata (raw installs, legacy rows with no -/// recorded package name) never reach the rpmdb probe. -fn apply_rpm_drift( - records: &mut [ComponentRecord], - state: &InstalledState, +fn apply_rpm_drift_to_record( + record: &mut ComponentRecord, + rpm_meta: Option<&RpmMetadata>, query: &dyn PackageQuery, + checked_at: &str, ) { - // Index RPM metadata by component name in a single pass so the per-record - // lookup is O(1) instead of re-scanning the object list for every record. - let rpm_meta: std::collections::HashMap<&str, &RpmMetadata> = state - .objects - .iter() - .filter(|o| o.kind == ObjectKind::Component) - .filter_map(|o| o.rpm_metadata.as_ref().map(|m| (o.name.as_str(), m))) - .collect(); - if rpm_meta.is_empty() { + // Only adjudicate drift on a clean live projection; never demote a + // failed/degraded/disabled record (see fn doc). This also bounds the + // rpm -q probes below to the records that can actually drift. + if !matches!(record.status.as_str(), "installed" | "adopted") { return; } - let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - for record in records.iter_mut() { - // Only adjudicate drift on a clean live projection; never demote a - // failed/degraded/disabled record (see fn doc). This also bounds the - // rpm -q probes below to the records that can actually drift. - if !matches!(record.status.as_str(), "installed" | "adopted") { - continue; - } - let Some(&meta) = rpm_meta.get(record.name.as_str()) else { - continue; - }; - let (status, reason) = match probe_rpm_drift(meta, query) { - Some(RpmDrift::Drifted { reason }) => ("drifted", reason), - Some(RpmDrift::Missing) => ( - "missing", - format!( - "package {} recorded in ANOLISA state is no longer present in rpmdb", - meta.package_name - ), + let Some(meta) = rpm_meta else { + return; + }; + let (status, reason) = match probe_rpm_drift(meta, query) { + Some(RpmDrift::Drifted { reason }) => ("drifted", reason), + Some(RpmDrift::Missing) => ( + "missing", + format!( + "package {} recorded in ANOLISA state is no longer present in rpmdb", + meta.package_name ), - None => continue, - }; - record.status = status.to_string(); - record.health.push(HealthEntry { - name: "rpm:drift".to_string(), - status: status.to_string(), - checked_at: checked_at.clone(), - reason: Some(reason), - }); - } + ), + None => return, + }; + record.status = status.to_string(); + record.health.push(HealthEntry { + name: "rpm:drift".to_string(), + status: status.to_string(), + checked_at: checked_at.to_string(), + reason: Some(reason), + }); } /// Build adapter summary records for `component` from the scan entries. @@ -495,6 +657,11 @@ fn record_from_object( ComponentRecord { name: obj.name.clone(), status: manifest_status, + scope: "none".to_string(), + active: true, + mutable_by_current_invocation: false, + shadowed_by: None, + state_path: None, version: Some(obj.version.clone()), installed_at: Some(obj.installed_at.clone()), last_operation_id: obj.last_operation_id.clone(), @@ -982,20 +1149,24 @@ fn render_human(records: &[ComponentRecord], verbose: bool, no_color: bool) { println!( "{}", color.header(format!( - "{:<28} {:<14} {:<10} {}", - "NAME", "STATUS", "VERSION", "INSTALLED_AT" + "{:<28} {:<8} {:<14} {:<10} {}", + "NAME", "SCOPE", "STATUS", "VERSION", "INSTALLED_AT" )) ); for record in records { let version = record.version.as_deref().unwrap_or("-"); let installed_at = record.installed_at.as_deref().unwrap_or("-"); println!( - "{name:<28} {status:<14} {version:<10} {installed_at}", + "{name:<28} {scope:<8} {status:<14} {version:<10} {installed_at}", name = record.name, + scope = record.scope, status = color.status(pad_right(&record.status, 14)), version = version, installed_at = color.muted(installed_at), ); + for (label, value) in scope_metadata_pairs(record) { + println!(" {} {}", color.label(format!("{label}:")), value); + } // RPM provenance for observed / adopted rpm-observed rows (§8). if let Some(pkg) = record.rpm_package.as_deref() { let repo = record.rpm_source_repo.as_deref().unwrap_or("unknown repo"); @@ -1092,6 +1263,29 @@ fn render_human(records: &[ComponentRecord], verbose: bool, no_color: bool) { } } +fn scope_metadata_pairs(record: &ComponentRecord) -> Vec<(&'static str, String)> { + let mut pairs = vec![ + ("active", record.active.to_string()), + ( + "mutable_by_current_invocation", + record.mutable_by_current_invocation.to_string(), + ), + ]; + if let Some(scope) = record.shadowed_by.as_deref() { + pairs.push(("shadowed_by", scope.to_string())); + } + if let Some(path) = record.state_path.as_deref() { + pairs.push(("state_path", path.to_string())); + } + pairs +} + +fn render_warnings(warnings: &[String]) { + for warning in warnings { + eprintln!("warning: {warning}"); + } +} + fn adapter_state_label(adapter: &AdapterSummaryRecord) -> &'static str { match (adapter.enabled, adapter.claim_status) { (_, Some(ClaimStatus::CleanupFailed)) => "cleanup_failed", @@ -1104,6 +1298,7 @@ fn adapter_state_label(adapter: &AdapterSummaryRecord) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::commands::state_view::{ScopedStateRoot, StateScope, StateView}; use crate::repo_config::RepoConfig; use crate::resolution::resolve_rpm_component_name; use anolisa_core::{ @@ -1159,6 +1354,229 @@ mod tests { } } + fn scoped_status_view(user_state: InstalledState, system_state: InstalledState) -> StateView { + let user_root = ScopedStateRoot { + scope: StateScope::User, + layout: FsLayout::user_with_overrides( + PathBuf::from("/tmp/anolisa-home"), + None, + None, + Some(PathBuf::from("/tmp/anolisa-user-state")), + None, + None, + ), + state_path: PathBuf::from("/tmp/anolisa-user-state/installed.toml"), + writable: true, + state: user_state, + }; + let system_root = ScopedStateRoot { + scope: StateScope::System, + layout: FsLayout::system(Some(PathBuf::from("/tmp/anolisa-system"))), + state_path: PathBuf::from("/tmp/anolisa-system-state/installed.toml"), + writable: false, + state: system_state, + }; + StateView { + writable: user_root.clone(), + visible_roots: vec![user_root, system_root], + warnings: Vec::new(), + } + } + + #[test] + fn user_status_view_includes_system_component() { + let user_state = InstalledState::default(); + let mut system_state = InstalledState::default(); + system_state.upsert_object(component_object( + "agentsight", + "0.1.0", + ObjectStatus::Installed, + )); + let view = scoped_status_view(user_state, system_state); + + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), + Some("agentsight"), + None, + None, + ); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].name, "agentsight"); + assert_eq!(records[0].status, "installed"); + assert_eq!(records[0].scope, "system"); + assert!(records[0].active); + assert!(!records[0].mutable_by_current_invocation); + assert_eq!( + records[0].state_path.as_deref(), + Some("/tmp/anolisa-system-state/installed.toml") + ); + } + + #[test] + fn scoped_status_view_uses_catalog_from_record_root() { + let user_home = tempfile::tempdir().expect("user home"); + let user_layout = FsLayout::user_with_overrides( + user_home.path().join("home"), + Some(user_home.path().join("data")), + Some(user_home.path().join("config")), + Some(user_home.path().join("state")), + Some(user_home.path().join("cache")), + Some(user_home.path().join("runtime")), + ); + std::fs::create_dir_all(&user_layout.bin_dir).expect("user bin dir"); + + let system_prefix = tempfile::tempdir().expect("system prefix"); + let system_layout = test_layout(system_prefix.path()); + let system_probe = system_layout.bin_dir.join("agentsight"); + std::fs::write(&system_probe, b"binary").expect("write system probe"); + + let user_manifest = format!( + r#" + [component] + name = "agentsight" + version = "0.1.0" + + [[health_checks]] + name = "binary" + kind = "file" + probe = "{}" + "#, + user_layout.bin_dir.join("agentsight").display() + ); + let system_manifest = format!( + r#" + [component] + name = "agentsight" + version = "0.1.0" + + [[health_checks]] + name = "binary" + kind = "file" + probe = "{}" + "#, + system_probe.display() + ); + let (user_catalog, _user_catalog_guard) = + catalog_with_component("agentsight", &user_manifest); + let (system_catalog, _system_catalog_guard) = + catalog_with_component("agentsight", &system_manifest); + + let user_state_path = user_layout.state_dir.join("installed.toml"); + let system_state_path = system_layout.state_dir.join("installed.toml"); + let user_root = ScopedStateRoot { + scope: StateScope::User, + layout: user_layout, + state_path: user_state_path.clone(), + writable: true, + state: InstalledState::default(), + }; + let mut system_state = InstalledState::default(); + system_state.upsert_object(component_object( + "agentsight", + "0.1.0", + ObjectStatus::Installed, + )); + let system_root = ScopedStateRoot { + scope: StateScope::System, + layout: system_layout, + state_path: system_state_path.clone(), + writable: false, + state: system_state, + }; + let view = StateView { + writable: user_root.clone(), + visible_roots: vec![user_root, system_root], + warnings: Vec::new(), + }; + let catalogs = ScopedCatalogs::from_entries(vec![ + (user_state_path, user_catalog), + (system_state_path, system_catalog), + ]); + + let records = select_components_from_view(&view, &catalogs, Some("agentsight"), None, None); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].scope, "system"); + assert_eq!(records[0].status, "installed"); + let health = records[0] + .health + .iter() + .find(|entry| entry.name == "agentsight:file:binary") + .expect("system catalog health entry present"); + assert_eq!(health.status, "ok"); + assert!( + health + .reason + .as_deref() + .is_some_and(|reason| reason.contains(&system_probe.display().to_string())), + "health reason must use system manifest path: {:?}", + health.reason + ); + } + + #[test] + fn named_status_view_reports_shadowed_system_record() { + let mut user_state = InstalledState::default(); + user_state.upsert_object(component_object( + "agentsight", + "0.2.0", + ObjectStatus::Installed, + )); + let mut system_state = InstalledState::default(); + system_state.upsert_object(component_object( + "agentsight", + "0.1.0", + ObjectStatus::Installed, + )); + let view = scoped_status_view(user_state, system_state); + + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), + Some("agentsight"), + None, + None, + ); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].scope, "user"); + assert!(records[0].active); + assert_eq!(records[1].scope, "system"); + assert!(!records[1].active); + assert_eq!(records[1].shadowed_by.as_deref(), Some("user")); + } + + #[test] + fn system_status_view_projects_only_visible_system_root() { + let mut system_state = InstalledState::default(); + system_state.upsert_object(component_object( + "agentsight", + "0.1.0", + ObjectStatus::Installed, + )); + let system_root = ScopedStateRoot { + scope: StateScope::System, + layout: FsLayout::system(Some(PathBuf::from("/tmp/anolisa-system"))), + state_path: PathBuf::from("/tmp/anolisa-system-state/installed.toml"), + writable: true, + state: system_state, + }; + let view = StateView { + writable: system_root.clone(), + visible_roots: vec![system_root], + warnings: Vec::new(), + }; + + let records = + select_components_from_view(&view, &ScopedCatalogs::default(), None, None, None); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].name, "agentsight"); + assert_eq!(records[0].scope, "system"); + } + /// A missing `installed.toml` is the fresh-install case and must /// surface as an empty result, not an error. Verifies the helper /// stack (`InstalledState::load` -> `select_components`) collapses @@ -2335,6 +2753,11 @@ mod tests { let record = ComponentRecord { name: "agentsight".to_string(), status: "installed".to_string(), + scope: "system".to_string(), + active: true, + mutable_by_current_invocation: true, + shadowed_by: None, + state_path: Some("/tmp/anolisa-system-state/installed.toml".to_string()), version: Some("0.1.0".to_string()), installed_at: Some("2026-06-01T10:00:00Z".to_string()), last_operation_id: None, @@ -2357,6 +2780,39 @@ mod tests { ); } + #[test] + fn scope_metadata_pairs_expose_mutability_and_state_path() { + let record = ComponentRecord { + name: "agentsight".to_string(), + status: "installed".to_string(), + scope: "system".to_string(), + active: false, + mutable_by_current_invocation: false, + shadowed_by: Some("user".to_string()), + state_path: Some("/tmp/anolisa-system-state/installed.toml".to_string()), + version: Some("0.1.0".to_string()), + installed_at: Some("2026-06-01T10:00:00Z".to_string()), + last_operation_id: None, + enabled_features: Vec::new(), + health: Vec::new(), + adapters: Vec::new(), + rpm_package: None, + rpm_evr: None, + rpm_source_repo: None, + provisioned_packages: Vec::new(), + }; + + let pairs = scope_metadata_pairs(&record); + + assert!(pairs.contains(&("active", "false".to_string()))); + assert!(pairs.contains(&("mutable_by_current_invocation", "false".to_string()))); + assert!(pairs.contains(&("shadowed_by", "user".to_string()))); + assert!(pairs.contains(&( + "state_path", + "/tmp/anolisa-system-state/installed.toml".to_string() + ))); + } + #[test] fn adapter_state_label_values() { let base = AdapterSummaryRecord { @@ -2742,26 +3198,16 @@ name = "copilot-shell" assert!(probe_rpm_drift(&meta, &q).is_none()); } - /// `apply_rpm_drift` overrides an adopted rpm-observed row to `drifted` and - /// records a `rpm:drift` health entry when rpmdb has moved on. + /// The scoped status selector overrides an adopted rpm-observed row to + /// `drifted` and records a `rpm:drift` health entry when rpmdb has moved on. #[test] - fn apply_rpm_drift_overrides_status_to_drifted() { + fn select_components_from_view_overrides_rpm_status_to_drifted() { let mut state = InstalledState::default(); state.upsert_object(rpm_observed_object( "copilot-shell", "copilot-shell", "2.2.0-1.al8", )); - let mut records = select_components( - &state, - &dummy_layout(), - None, - "system", - Some("copilot-shell"), - None, - ); - assert_eq!(records[0].status, "adopted", "baseline before drift"); - let q = FakeQuery { installed: vec![( "copilot-shell".to_string(), @@ -2769,7 +3215,15 @@ name = "copilot-shell" )], origins: Vec::new(), }; - apply_rpm_drift(&mut records, &state, &q); + let view = scoped_status_view(InstalledState::default(), state); + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), + Some("copilot-shell"), + None, + Some(&q), + ); + assert_eq!(records[0].status, "drifted"); assert!( records[0] @@ -2780,26 +3234,26 @@ name = "copilot-shell" ); } - /// `apply_rpm_drift` overrides to `missing` when rpmdb no longer has the - /// package (the `rpm -e` case must not be silently reinstalled). + /// The scoped status selector overrides to `missing` when rpmdb no longer + /// has the package (the `rpm -e` case must not be silently reinstalled). #[test] - fn apply_rpm_drift_overrides_status_to_missing() { + fn select_components_from_view_overrides_rpm_status_to_missing() { let mut state = InstalledState::default(); state.upsert_object(rpm_observed_object( "copilot-shell", "copilot-shell", "2.2.0-1.al8", )); - let mut records = select_components( - &state, - &dummy_layout(), - None, - "system", + let q = FakeQuery::default(); + let view = scoped_status_view(InstalledState::default(), state); + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), Some("copilot-shell"), None, + Some(&q), ); - let q = FakeQuery::default(); - apply_rpm_drift(&mut records, &state, &q); + assert_eq!(records[0].status, "missing"); assert!( records[0] @@ -2812,23 +3266,23 @@ name = "copilot-shell" /// A raw component (no RPM metadata) is never touched by drift adjudication, /// even when the injected query would report the package absent. #[test] - fn apply_rpm_drift_leaves_non_rpm_component_untouched() { + fn select_components_from_view_leaves_non_rpm_component_untouched() { let mut state = InstalledState::default(); state.upsert_object(component_object( "agentsight", "0.1.0", ObjectStatus::Installed, )); - let mut records = select_components( - &state, - &dummy_layout(), - None, - "system", + let q = FakeQuery::default(); + let view = scoped_status_view(InstalledState::default(), state); + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), Some("agentsight"), None, + Some(&q), ); - let q = FakeQuery::default(); - apply_rpm_drift(&mut records, &state, &q); + assert_eq!(records[0].status, "installed", "raw row must not drift"); assert!( !records[0].health.iter().any(|h| h.name == "rpm:drift"), @@ -2840,28 +3294,11 @@ name = "copilot-shell" /// escalated past a clean projection: a `failed` RPM row keeps its /// more-severe status even when rpmdb has moved on, and gains no drift entry. #[test] - fn apply_rpm_drift_keeps_escalated_status() { + fn select_components_from_view_keeps_escalated_rpm_status() { let mut state = InstalledState::default(); - state.upsert_object(rpm_observed_object( - "copilot-shell", - "copilot-shell", - "2.2.0-1.al8", - )); - // A record whose live projection already escalated past `installed`. - let mut records = vec![ComponentRecord { - name: "copilot-shell".to_string(), - status: "failed".to_string(), - version: Some("2.2.0-1.al8".to_string()), - installed_at: None, - last_operation_id: None, - enabled_features: Vec::new(), - health: Vec::new(), - adapters: Vec::new(), - rpm_package: None, - rpm_evr: None, - rpm_source_repo: None, - provisioned_packages: Vec::new(), - }]; + let mut object = rpm_observed_object("copilot-shell", "copilot-shell", "2.2.0-1.al8"); + object.status = ObjectStatus::Failed; + state.upsert_object(object); // rpmdb has drifted, but the failed status must survive untouched. let q = FakeQuery { installed: vec![( @@ -2870,7 +3307,15 @@ name = "copilot-shell" )], origins: Vec::new(), }; - apply_rpm_drift(&mut records, &state, &q); + let view = scoped_status_view(InstalledState::default(), state); + let records = select_components_from_view( + &view, + &ScopedCatalogs::default(), + Some("copilot-shell"), + None, + Some(&q), + ); + assert_eq!( records[0].status, "failed", "escalated status must not be demoted to drifted",