merge: plugin package resolver

This commit is contained in:
2026-06-16 00:27:15 +09:00
11 changed files with 2116 additions and 35 deletions
+1
View File
@@ -12,6 +12,7 @@ protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
sha2 = "0.10"
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
+18
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
@@ -52,6 +53,10 @@ pub struct PodManifestConfig {
/// disabled after cascade merge.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@@ -444,6 +449,7 @@ impl PodManifestConfig {
PermissionConfigPartial::merge,
),
feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
compaction: merge_option(
self.compaction,
upper.compaction,
@@ -463,6 +469,16 @@ impl SkillsConfig {
}
}
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
impl WebConfig {
fn merge(self, upper: Self) -> Self {
Self {
@@ -827,6 +843,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
session,
permissions,
feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
compaction,
web: cfg.web,
memory: cfg.memory,
@@ -873,6 +890,7 @@ mod tests {
delegation_scope: ScopeConfig::default(),
permissions: None,
feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
session: None,
compaction: None,
web: None,
+36
View File
@@ -2,6 +2,7 @@ mod config;
pub mod defaults;
mod model;
pub mod paths;
pub mod plugin;
mod profile;
mod scope;
@@ -57,6 +58,10 @@ pub struct PodManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
@@ -867,6 +872,37 @@ model_id = "claude-sonnet-4-20250514"
assert!(PodManifest::from_toml(toml).is_err());
}
#[test]
fn parse_plugin_enablement_config() {
let toml = format!(
"{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\
id = \"project:example\"\n\
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
);
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.plugins.enabled.len(), 1);
let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example");
assert_eq!(
enabled.version.as_ref().map(|version| version.0.as_str()),
Some("0.1.0")
);
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
assert_eq!(
enabled
.config
.as_ref()
.and_then(|value| value.get("greeting"))
.and_then(|value| value.as_str()),
Some("hello")
);
}
#[test]
fn parse_max_turns() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
File diff suppressed because it is too large Load Diff
+4
View File
@@ -17,6 +17,7 @@ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
};
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
@@ -626,6 +627,7 @@ fn resolve_lua_profile_value(
session: profile.session,
permissions: profile.permissions,
feature: profile.feature,
plugins: profile.plugins,
compaction,
web: profile.web,
memory: profile.memory,
@@ -687,6 +689,8 @@ struct ProfileConfig {
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
plugins: PluginConfig,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
+7
View File
@@ -7,6 +7,7 @@ use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{
Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ScopeConfig, ScopeRule, paths,
plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup},
};
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use session_store::{FsStore, SegmentId, Store};
@@ -184,9 +185,15 @@ where
apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?;
}
apply_session_restore_overrides(&mut manifest, cli)?;
apply_plugin_resolution_plan(&mut manifest, &workspace_root);
Ok((manifest, loader))
}
fn apply_plugin_resolution_plan(manifest: &mut PodManifest, workspace_root: &Path) {
let options = PluginDiscoveryOptions::new(workspace_root);
manifest.plugins = resolve_plugin_config_for_startup(&manifest.plugins, &options);
}
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
if let Some(pod_name) = cli.pod.as_deref() {
manifest.pod.name = pod_name.to_string();
+84 -5
View File
@@ -924,11 +924,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active);
if self.manifest.profile.is_some() {
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
}
metadata
pod_metadata_for_manifest(&self.manifest, active)
}
fn write_pod_metadata_pending(&self) -> Result<(), PodError> {
@@ -4321,6 +4317,21 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig {
config
}
fn pod_metadata_for_manifest(
manifest: &PodManifest,
active: Option<PodActiveSegmentRef>,
) -> PodMetadata {
let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active);
if should_persist_resolved_manifest_snapshot(manifest) {
metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok();
}
metadata
}
fn should_persist_resolved_manifest_snapshot(manifest: &PodManifest) -> bool {
manifest.profile.is_some() || manifest.plugins.has_resolved_plan()
}
fn restore_manifest_from_pod_metadata_snapshot(
pod_name: &str,
snapshot: Option<serde_json::Value>,
@@ -5294,6 +5305,74 @@ permission = "write"
Permission::Write
);
}
#[test]
fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() {
let mut manifest = PodManifest::from_toml(
r#"
[pod]
name = "plugin-snapshot"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
instruction = "saved"
[[scope.allow]]
target = "/snapshot/workspace"
permission = "read"
"#,
)
.unwrap();
assert!(manifest.profile.is_none());
assert!(
pod_metadata_for_manifest(&manifest, None)
.resolved_manifest_snapshot
.is_none()
);
manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord {
identity: manifest::plugin::SourceQualifiedPluginId::new(
manifest::plugin::PluginSourceKind::Project,
"example",
),
source: manifest::plugin::PluginSourceKind::Project,
package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"),
package_label: "example.yoi-plugin".to_string(),
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
version: "0.1.0".to_string(),
manifest: manifest::plugin::PluginPackageManifest {
schema_version: 1,
id: "example".to_string(),
name: "Example".to_string(),
version: "0.1.0".to_string(),
description: None,
surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None,
hooks: vec![],
},
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}];
let metadata = pod_metadata_for_manifest(&manifest, None);
let snapshot = metadata
.resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted");
let restored: PodManifest = serde_json::from_value(snapshot).unwrap();
assert!(restored.profile.is_none());
assert_eq!(restored.plugins.resolved.len(), 1);
assert_eq!(
restored.plugins.resolved[0].digest,
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(restored.plugins.resolved[0].version, "0.1.0");
}
}
#[cfg(test)]
+1
View File
@@ -775,6 +775,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
rules: p.rules.clone(),
}),
feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(),
compaction: manifest
.compaction
.as_ref()