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:
@ -182,17 +182,18 @@ pub use plugin::{
|
||||
PluginDiagnosticMessage, PluginEnvDiagnostic, PluginEnvRequirement, PluginEventBatch,
|
||||
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
|
||||
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
|
||||
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
|
||||
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
|
||||
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
|
||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReloadResult,
|
||||
PluginReview, PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin,
|
||||
PluginStorageAccess, PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput,
|
||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput,
|
||||
ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput,
|
||||
UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
|
||||
@ -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(®istry)
|
||||
.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(),
|
||||
®istry,
|
||||
)
|
||||
.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()));
|
||||
|
||||
Reference in New Issue
Block a user