Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 139 additions & 47 deletions crates/core/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,23 +1205,15 @@ fn merge_plugin_components(left: &mut Json, right: Json) {
*left = right;
return;
};
let mut base_slots: HashMap<String, Vec<usize>> = HashMap::new();
for (index, component) in left_components.iter().enumerate() {
if let Some(kind) = component_kind(component) {
base_slots.entry(kind.to_string()).or_default().push(index);
}
}
let base_component_count = left_components.len();
let mut consumed: HashMap<String, usize> = HashMap::new();
for component in right_components {
let Some(kind) = component_kind(&component).map(str::to_owned) else {
left_components.push(component);
continue;
};
let nth = consumed.entry(kind.clone()).or_insert(0);
let slot = base_slots
.get(&kind)
.and_then(|slots| slots.get(*nth))
.copied();
let slot = nth_component_by_kind(&left_components[..base_component_count], &kind, *nth);
*nth += 1;
match slot {
Some(index) => merge_plugin_component(&mut left_components[index], component),
Expand Down Expand Up @@ -1326,6 +1318,15 @@ fn component_kind(component: &Json) -> Option<&str> {
component.get("kind").and_then(Json::as_str)
}

fn nth_component_by_kind(components: &[Json], kind: &str, nth: usize) -> Option<usize> {
components
.iter()
.enumerate()
.filter(|(_index, component)| component_kind(component) == Some(kind))
.nth(nth)
.map(|(index, _component)| index)
}

/// Returns the JSON Schema for the canonical plugin configuration document.
#[cfg(feature = "schema")]
pub fn plugin_config_schema() -> Json {
Expand Down Expand Up @@ -1355,12 +1356,20 @@ pub fn plugin_config_schema() -> Json {
/// is removed before the new configuration is activated.
#[doc(hidden)]
pub async fn initialize_plugins_exact(config: PluginConfig) -> Result<ConfigReport> {
initialize_plugins_with_diagnostics(config, Vec::new()).await
}

async fn initialize_plugins_with_diagnostics(
config: PluginConfig,
diagnostics: Vec<ConfigDiagnostic>,
) -> Result<ConfigReport> {
run_owned_plugin_mutation("plugin initialization", move || async move {
let lease = LegacyPluginMutationLease::acquire()?;
let rollback_failures = Arc::new(Mutex::new(Vec::new()));
let initialization = tokio::spawn(initialize_plugins_exact_inner(
config,
Some(Arc::clone(&rollback_failures)),
diagnostics,
))
.await
.map_err(|error| {
Expand Down Expand Up @@ -1477,14 +1486,16 @@ pub(crate) async fn initialize_plugins_exact_for_host(
config: PluginConfig,
owner_id: u64,
rollback_failures: Arc<Mutex<Vec<String>>>,
diagnostics: Vec<ConfigDiagnostic>,
) -> Result<ConfigReport> {
verify_plugin_host_owner(owner_id)?;
initialize_plugins_exact_inner(config, Some(rollback_failures)).await
initialize_plugins_exact_inner(config, Some(rollback_failures), diagnostics).await
}

async fn initialize_plugins_exact_inner(
config: PluginConfig,
rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
diagnostics: Vec<ConfigDiagnostic>,
) -> Result<ConfigReport> {
let enabled_component_count = config
.components
Expand All @@ -1497,7 +1508,10 @@ async fn initialize_plugins_exact_inner(
component_count = enabled_component_count;
"Plugin configuration activation started"
);
let report = validate_plugin_config(&config);
let mut report = ConfigReport { diagnostics };
report
.diagnostics
.extend(validate_plugin_config(&config).diagnostics);
if report.has_errors() {
return Err(PluginError::InvalidConfig(join_error_messages(&report)));
}
Expand Down Expand Up @@ -1541,10 +1555,9 @@ async fn initialize_plugins_exact_inner(
.await
{
Ok(registrations) => {
let previous_report = validate_plugin_config(&previous_state.config);
store_active_plugin_configuration(
previous_state.config,
previous_report,
previous_state.report,
registrations,
)?;
log::warn!(
Expand Down Expand Up @@ -1598,32 +1611,49 @@ async fn initialize_plugin_components_catching_panics(
/// Validates and activates `config` layered on top of the discovered
/// `plugins.toml` configuration, so a direct integration sees the same file
/// layering as the gateway. Each file's schema version is validated before
/// layering. Non-default values in `config` win on conflicts, while default
/// `policy` and `enabled` values inherit from a matching file entry and
/// component `config` bodies merge field-by-field. Delegates to
/// [`initialize_plugins_exact`]. Call that function directly when `config`
/// is already fully resolved and every value must be applied exactly.
/// layering. Declaring a component in `config` applies its `enabled` value,
/// while default policy values inherit from discovered files and component
/// `config` bodies merge field-by-field. The resolved configuration and
/// diagnostics are passed to the shared `initialize_plugins_with_diagnostics`
/// helper. Call [`initialize_plugins_exact`] directly when `config` is already
/// fully resolved and every value must be applied exactly.
pub async fn initialize_plugins(config: PluginConfig) -> Result<ConfigReport> {
let config = resolve_plugin_config(config)?;
initialize_plugins_exact(config).await
let resolved = resolve_plugin_config(config)?;
initialize_plugins_with_diagnostics(resolved.config, resolved.diagnostics).await
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Layers `config` over the default discovered `plugins.toml` files.
///
/// This is crate-visible so owned dynamic-plugin activation can use the same
/// one-time configuration resolution as regular harness-native initialization.
pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result<PluginConfig> {
let mut base = resolve_default_file_plugin_config()?;
pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result<ResolvedPluginConfig> {
let discovered = resolve_default_file_plugin_config()?;
let diagnostics = programmatic_enable_override_diagnostics(
&discovered.value,
&discovered.enabled_sources,
&config,
);
let mut base = discovered.value;
layer_config(&mut base, plugin_config_overlay_value(&config)?);
Ok(serde_json::from_value(base)?)
Ok(ResolvedPluginConfig {
config: serde_json::from_value(base)?,
diagnostics,
})
}

pub(crate) struct ResolvedPluginConfig {
pub(crate) config: PluginConfig,
pub(crate) diagnostics: Vec<ConfigDiagnostic>,
}

/// Serializes a typed configuration as a discovery overlay.
///
/// A [`PluginConfig`] cannot record whether a default-valued field was supplied
/// explicitly or filled by serde. Treating those defaults as overlay values
/// would mask discovered file settings on every library initialization. Exact
/// callers bypass discovery through [`initialize_plugins_exact`].
/// would mask discovered file settings on every library initialization. A
/// declared component is an activation intent, so its `enabled` value remains
/// in the overlay. Exact callers bypass discovery through
/// [`initialize_plugins_exact`].
fn plugin_config_overlay_value(config: &PluginConfig) -> Result<Json> {
let mut overlay = serde_json::to_value(config)?;
let Json::Object(root) = &mut overlay else {
Expand All @@ -1635,7 +1665,6 @@ fn plugin_config_overlay_value(config: &PluginConfig) -> Result<Json> {
}

remove_default_policy_overlay(root, &config.policy);
remove_default_component_enabled_overlays(root, &config.components);

Ok(overlay)
}
Expand Down Expand Up @@ -1668,30 +1697,25 @@ fn remove_default_policy_overlay(root: &mut Map<String, Json>, config: &ConfigPo
}
}

fn remove_default_component_enabled_overlays(
root: &mut Map<String, Json>,
configured: &[PluginComponentSpec],
) {
let Some(Json::Array(components)) = root.get_mut("components") else {
return;
};
for (component, typed) in components.iter_mut().zip(configured) {
if typed.enabled == default_enabled()
&& let Json::Object(component) = component
{
component.remove("enabled");
}
}
}

/// Resolves the default `plugins.toml` layering into one JSON document, or an
/// empty object when no plugin file exists.
fn resolve_default_file_plugin_config() -> Result<Json> {
fn resolve_default_file_plugin_config() -> Result<DiscoveredPluginConfig> {
let paths =
default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir());
Ok(load_plugin_config_files(paths)?
let documents = read_plugin_config_files(paths)?;
let enabled_sources = component_enabled_sources(&documents);
let value = merge_plugin_config_documents(documents)?
.map(|(value, _sources)| value)
.unwrap_or_else(|| Json::Object(Map::new())))
.unwrap_or_else(|| Json::Object(Map::new()));
Ok(DiscoveredPluginConfig {
value,
enabled_sources,
})
}

struct DiscoveredPluginConfig {
value: Json,
enabled_sources: HashMap<String, PathBuf>,
}

use std::path::{Path, PathBuf};
Expand All @@ -1701,6 +1725,13 @@ use std::path::{Path, PathBuf};
/// when none exist. Internal: `pub` only for cross-crate reuse by the gateway.
#[doc(hidden)]
pub fn load_plugin_config_files<I>(paths: I) -> Result<Option<(Json, Vec<PathBuf>)>>
where
I: IntoIterator<Item = PathBuf>,
{
merge_plugin_config_documents(read_plugin_config_files(paths)?)
}

fn read_plugin_config_files<I>(paths: I) -> Result<Vec<(PathBuf, Json)>>
where
I: IntoIterator<Item = PathBuf>,
{
Expand All @@ -1717,7 +1748,68 @@ where
})?;
documents.push((path, serde_json::to_value(parsed)?));
}
merge_plugin_config_documents(documents)
Ok(documents)
}

fn component_enabled_sources(documents: &[(PathBuf, Json)]) -> HashMap<String, PathBuf> {
let mut sources = HashMap::new();
for (path, document) in documents {
let Some(components) = document.get("components").and_then(Json::as_array) else {
continue;
};
for component in components {
let Some(kind) = component_kind(component) else {
continue;
};
if component.get("enabled").and_then(Json::as_bool).is_some() {
sources.insert(kind.to_string(), path.clone());
}
}
}
sources
}

fn programmatic_enable_override_diagnostics(
discovered: &Json,
enabled_sources: &HashMap<String, PathBuf>,
programmatic: &PluginConfig,
) -> Vec<ConfigDiagnostic> {
let Some(discovered_components) = discovered.get("components").and_then(Json::as_array) else {
return Vec::new();
};
let mut consumed = HashMap::new();
let mut diagnostics = Vec::new();
for component in &programmatic.components {
let nth = consumed.entry(component.kind.as_str()).or_insert(0usize);
let discovered_component =
nth_component_by_kind(discovered_components, &component.kind, *nth)
.and_then(|index| discovered_components.get(index));
*nth += 1;
if !component.enabled
|| discovered_component
.and_then(|component| component.get("enabled"))
.and_then(Json::as_bool)
!= Some(false)
{
continue;
}

let source = enabled_sources
.get(&component.kind)
.map(|path| format!(" from {}", path.display()))
.unwrap_or_default();
diagnostics.push(ConfigDiagnostic {
level: DiagnosticLevel::Warning,
code: "plugin.component_reenabled".to_string(),
component: Some(component.kind.clone()),
field: Some("enabled".to_string()),
message: format!(
"programmatic configuration enabled plugin component '{}' and overrode enabled = false{source}",
component.kind
),
});
}
diagnostics
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Removes physical duplicates while preserving the highest-precedence path.
Expand Down
11 changes: 7 additions & 4 deletions crates/core/src/plugin/dynamic/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ impl PluginHostActivation {
{
let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
validate_dynamic_plugin_specs(&dynamic_plugins)?;
Self::activate_validated(config, dynamic_plugins).await
Self::activate_validated(config, dynamic_plugins, Vec::new()).await
}

/// Load dynamic plugins after layering `config` over discovered `plugins.toml` files.
Expand All @@ -97,23 +97,25 @@ impl PluginHostActivation {
{
let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
validate_dynamic_plugin_specs(&dynamic_plugins)?;
let config = resolve_plugin_config(config)?;
Self::activate_validated(config, dynamic_plugins).await
let resolved = resolve_plugin_config(config)?;
Self::activate_validated(resolved.config, dynamic_plugins, resolved.diagnostics).await
}

async fn activate_validated(
config: PluginConfig,
dynamic_plugins: Vec<DynamicPluginActivationSpec>,
diagnostics: Vec<crate::plugin::ConfigDiagnostic>,
) -> Result<(Self, ConfigReport)> {
run_owned_plugin_mutation("dynamic plugin activation", move || async move {
Self::activate_inner(config, dynamic_plugins).await
Self::activate_inner(config, dynamic_plugins, diagnostics).await
})
.await
}

async fn activate_inner(
mut config: PluginConfig,
dynamic_plugins: Vec<DynamicPluginActivationSpec>,
diagnostics: Vec<crate::plugin::ConfigDiagnostic>,
) -> Result<(Self, ConfigReport)> {
let dynamic_plugin_count = dynamic_plugins.len();
log::info!(
Expand Down Expand Up @@ -190,6 +192,7 @@ impl PluginHostActivation {
config,
owner_id,
Arc::clone(&rollback_failures),
diagnostics,
))
.await
.map_err(|error| {
Expand Down
Loading
Loading