feat(plugins): hot reload des plugins sans relancer IdeA — use case ReloadPlugin (invalidation registry/catalogue, resultat hashes+changed), tools MCP d'administration plugin agent-facing (mcp/plugins.rs + PluginToolProvider), evenement domaine plugin_reloaded relaye sur le wire (dto/events), exposition openai_tools rafraichie, wiring app-tauri — #271/#269 (QA verte #273: application 32, mcp_server 38, openai_tools 10, plugin_install_load 5, plugin 10, wire 1 — 96 tests ciblés; app-tauri compile sans desktop runtime)

This commit is contained in:
2026-09-07 19:29:25 +02:00
parent 8d2be75f8f
commit 805b1b4d0d
14 changed files with 863 additions and 32 deletions

View File

@ -117,6 +117,36 @@ pub struct PluginInstallResult {
pub restart_required: bool,
}
/// Input for reloading an installed plugin from its recorded source.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReloadPluginInput {
/// Installed plugin id.
pub plugin_id: String,
/// Optional optimistic-concurrency guard against the installed content hash.
pub expected_content_hash: Option<String>,
}
/// Result of reloading an installed plugin package.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReloadResult {
/// Reloaded plugin admin projection.
pub plugin: PluginAdmin,
/// Content hash before the reload.
pub old_content_hash: String,
/// Content hash after the reload.
pub new_content_hash: String,
/// Whether the package contents changed.
pub changed: bool,
/// Reloaded runtime catalogue entry when the plugin is runtime-active.
pub runtime_catalog_entry: Option<PluginRuntimePlugin>,
/// MCP server state after reconciliation.
pub mcp_statuses: domain::PluginMcpStatusSet,
/// Non-fatal post-commit warnings.
pub warnings: Vec<String>,
}
/// Uninstall result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -2396,6 +2426,171 @@ impl InstallPluginFromDirectory {
}
}
/// Reloads an installed directory plugin without restarting IdeA.
pub struct ReloadPlugin {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl ReloadPlugin {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
events,
mcp,
}
}
/// Executes the use case from the source persisted in the plugin registry.
pub async fn execute(&self, input: ReloadPluginInput) -> Result<PluginReloadResult, AppError> {
let plugin_id =
PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
let old_entry = registry
.find(&plugin_id)
.cloned()
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
let old_content_hash = old_entry.content_hash.as_str().to_owned();
if let Some(expected) = input.expected_content_hash {
if expected != old_content_hash {
return Err(AppError::Conflict(old_content_hash));
}
}
let source_path = match &old_entry.source {
PluginInstallSource::Directory { path_label } => path_label.clone(),
PluginInstallSource::Archive { .. } => {
return Err(AppError::Invalid(format!(
"plugin `{}` cannot be reloaded: archive install sources are unsupported",
plugin_id.as_str()
)))
}
};
let staged = self
.packages
.install_from_directory(&LocalPath::new(source_path))
.await
.map_err(map_store)?;
let review =
review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await?;
if review.manifest.id != plugin_id {
return Err(AppError::Invalid(format!(
"reload manifest id `{}` does not match installed plugin `{}`",
review.manifest.id.as_str(),
plugin_id.as_str()
)));
}
let new_content_hash = review.content_hash.clone();
let changed = old_content_hash != new_content_hash;
self.packages
.commit_install(staged, &plugin_id)
.await
.map_err(map_store)?;
let mut entry = PluginRegistryEntry {
content_hash: ContentHash::new(new_content_hash.clone())
.map_err(|e| AppError::Invalid(e.to_string()))?,
restart_required: false,
error: None,
..old_entry
};
let runtime_catalog_entry = if entry.lifecycle_state.is_runtime_active() {
match runtime_plugin_from_entry(
self.packages.as_ref(),
self.validator.as_ref(),
entry.clone(),
)
.await
{
Ok(runtime) => Some(runtime),
Err(err) => {
entry.lifecycle_state = PluginLifecycleState::Invalid;
entry.error = Some(format!(
"runtime contributions disabled after reload: {err}"
));
None
}
}
} else {
None
};
registry.upsert(entry.clone());
self.registry
.save_registry(&registry)
.await
.map_err(map_registry)?;
self.events.publish(DomainEvent::PluginReloaded {
plugin_id: plugin_id.clone(),
old_content_hash: old_content_hash.clone(),
new_content_hash: new_content_hash.clone(),
changed,
});
let mut warnings = Vec::new();
let mcp_statuses = match active_mcp_specs(
self.packages.as_ref(),
self.validator.as_ref(),
&registry,
)
.await
{
Ok((specs, invalid_plugins)) => {
persist_invalid_runtime_plugins(
self.registry.as_ref(),
&mut registry,
invalid_plugins,
)
.await;
match self.mcp.reconcile(specs).await {
Ok(statuses) => statuses,
Err(err) => {
warnings.push(format!("MCP reconcile failed after reload: {err}"));
domain::PluginMcpStatusSet::default()
}
}
}
Err(err) => {
warnings.push(format!(
"MCP reconcile preparation failed after reload: {err}"
));
domain::PluginMcpStatusSet::default()
}
};
let plugin = admin_from_descriptor(
PluginDescriptor {
manifest: review.manifest,
registry: entry,
},
self.packages.as_ref(),
)?;
Ok(PluginReloadResult {
plugin,
old_content_hash,
new_content_hash,
changed,
runtime_catalog_entry,
mcp_statuses,
warnings,
})
}
}
async fn install_from_staged(
packages: &dyn PluginPackageStore,
registry_store: &dyn PluginRegistryStore,
@ -3497,6 +3692,14 @@ mod tests {
}
fn with_manifest_and_staged_count(bytes: Vec<u8>, staged_count: usize) -> Self {
Self::with_manifest_staged_count_and_hash(bytes, staged_count, "abc123")
}
fn with_manifest_staged_count_and_hash(
bytes: Vec<u8>,
staged_count: usize,
staged_hash: &str,
) -> Self {
let mut manifests = HashMap::new();
manifests.insert("dev.acme.gitgraph".to_owned(), bytes);
let staged = (0..staged_count)
@ -3505,7 +3708,7 @@ mod tests {
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: content_hash("abc123"),
content_hash: content_hash(staged_hash),
})
.collect();
Self {
@ -3971,6 +4174,160 @@ mod tests {
assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph");
}
#[tokio::test]
async fn reload_directory_plugin_updates_hash_publishes_event_and_reconciles_mcp() {
let packages = Arc::new(FakePackages::with_manifest_staged_count_and_hash(
valid_manifest(),
1,
"def456",
));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages,
registry.clone(),
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let result = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: Some("abc123".to_owned()),
})
.await
.unwrap();
assert!(result.changed);
assert_eq!(result.old_content_hash, "abc123");
assert_eq!(result.new_content_hash, "def456");
assert!(result.runtime_catalog_entry.is_some());
assert_eq!(result.mcp_statuses.servers.len(), 1);
let saved = registry.load_registry().await.unwrap();
let entry = saved.find(&plugin_id()).unwrap();
assert_eq!(entry.content_hash.as_str(), "def456");
assert!(!entry.restart_required);
assert_eq!(mcp.reconciles.lock().unwrap().len(), 1);
assert_eq!(
events.events.lock().unwrap().as_slice(),
&[DomainEvent::PluginReloaded {
plugin_id: plugin_id(),
old_content_hash: "abc123".to_owned(),
new_content_hash: "def456".to_owned(),
changed: true,
}]
);
}
#[tokio::test]
async fn reload_with_identical_hash_is_idempotent_but_still_reconciles_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages,
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let result = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: None,
})
.await
.unwrap();
assert!(!result.changed);
assert_eq!(result.old_content_hash, "abc123");
assert_eq!(result.new_content_hash, "abc123");
assert_eq!(mcp.reconciles.lock().unwrap().len(), 1);
assert_eq!(
events.events.lock().unwrap().as_slice(),
&[DomainEvent::PluginReloaded {
plugin_id: plugin_id(),
old_content_hash: "abc123".to_owned(),
new_content_hash: "abc123".to_owned(),
changed: false,
}]
);
}
#[tokio::test]
async fn reload_rejects_stale_hash_before_staging_package() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages.clone(),
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let error = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: Some("stale".to_owned()),
})
.await
.unwrap_err();
assert_eq!(error, AppError::Conflict("abc123".to_owned()));
assert_eq!(packages.staged.lock().unwrap().len(), 1);
assert!(events.events.lock().unwrap().is_empty());
assert!(mcp.reconciles.lock().unwrap().is_empty());
}
#[tokio::test]
async fn reload_rejects_archive_source() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let mut seeded = registry_with(PluginLifecycleState::Enabled);
seeded.plugins[0].source = PluginInstallSource::Archive {
path_label: "/source/plugin.zip".to_owned(),
};
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(seeded),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages.clone(),
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let error = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: None,
})
.await
.unwrap_err();
assert!(
matches!(error, AppError::Invalid(message) if message.contains("archive install sources are unsupported"))
);
assert_eq!(packages.staged.lock().unwrap().len(), 1);
assert!(events.events.lock().unwrap().is_empty());
assert!(mcp.reconciles.lock().unwrap().is_empty());
}
#[tokio::test]
async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));