merge(plugins): hot reload des plugins sans relancer IdeA — use case ReloadPlugin + tools MCP d'administration plugin agent-facing + evenement wire plugin_reloaded — #269/#271 (QA verte #273: 96 tests cibles verts, app-tauri compile; #272 sans code V1 — surface agent-facing uniquement, UX close)
This commit is contained in:
@ -410,6 +410,7 @@ pub fn run() {
|
||||
plugins::plugin_review_package,
|
||||
plugins::plugin_install_from_archive,
|
||||
plugins::plugin_install_from_directory,
|
||||
plugins::plugin_reload,
|
||||
plugins::plugin_set_enabled,
|
||||
plugins::plugin_uninstall,
|
||||
plugins::plugin_list_runtime_contributions,
|
||||
|
||||
@ -9,12 +9,13 @@ use backend::dto::{
|
||||
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
|
||||
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
||||
PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReloadPluginDto,
|
||||
ReviewPluginPackageDto,
|
||||
};
|
||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||
use domain::{PluginId, RelativePath};
|
||||
@ -122,6 +123,20 @@ pub async fn plugin_install_from_directory(
|
||||
result
|
||||
}
|
||||
|
||||
/// Hot-reloads a plugin from its recorded directory source.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_reload(
|
||||
input: ReloadPluginDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginReloadResultDto, ErrorDto> {
|
||||
state
|
||||
.reload_plugin
|
||||
.execute(input.into())
|
||||
.await
|
||||
.map(PluginReloadResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Enables or disables a plugin.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_set_enabled(
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -199,6 +199,59 @@ impl From<application::PluginInstallResult> for PluginInstallResultDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin reload request DTO.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReloadPluginDto {
|
||||
/// Installed plugin id.
|
||||
pub plugin_id: String,
|
||||
/// Optional installed-hash concurrency guard.
|
||||
pub expected_content_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ReloadPluginDto> for application::ReloadPluginInput {
|
||||
fn from(value: ReloadPluginDto) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
expected_content_hash: value.expected_content_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin hot-reload result DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginReloadResultDto {
|
||||
/// Reloaded plugin.
|
||||
pub plugin: PluginAdminDto,
|
||||
/// Previous content hash.
|
||||
pub old_content_hash: String,
|
||||
/// New content hash.
|
||||
pub new_content_hash: String,
|
||||
/// Whether the content changed.
|
||||
pub changed: bool,
|
||||
/// Runtime catalogue entry when active.
|
||||
pub runtime_catalog_entry: Option<application::PluginRuntimePlugin>,
|
||||
/// MCP status after reconciliation.
|
||||
pub mcp_statuses: domain::PluginMcpStatusSet,
|
||||
/// Non-fatal warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<application::PluginReloadResult> for PluginReloadResultDto {
|
||||
fn from(value: application::PluginReloadResult) -> Self {
|
||||
Self {
|
||||
plugin: value.plugin.into(),
|
||||
old_content_hash: value.old_content_hash,
|
||||
new_content_hash: value.new_content_hash,
|
||||
changed: value.changed,
|
||||
runtime_catalog_entry: value.runtime_catalog_entry,
|
||||
mcp_statuses: value.mcp_statuses,
|
||||
warnings: value.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin uninstall result DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -127,6 +127,18 @@ pub enum DomainEventDto {
|
||||
/// Installed version.
|
||||
version: String,
|
||||
},
|
||||
/// A plugin package was hot-reloaded.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginReloaded {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Previous content hash.
|
||||
old_content_hash: String,
|
||||
/// New content hash.
|
||||
new_content_hash: String,
|
||||
/// Whether the content changed.
|
||||
changed: bool,
|
||||
},
|
||||
/// A plugin was enabled.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginEnabled {
|
||||
@ -782,6 +794,17 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
version: version.as_str().to_owned(),
|
||||
},
|
||||
DomainEvent::PluginReloaded {
|
||||
plugin_id,
|
||||
old_content_hash,
|
||||
new_content_hash,
|
||||
changed,
|
||||
} => Self::PluginReloaded {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
old_content_hash: old_content_hash.clone(),
|
||||
new_content_hash: new_content_hash.clone(),
|
||||
changed: *changed,
|
||||
},
|
||||
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
},
|
||||
@ -1327,6 +1350,24 @@ mod tests {
|
||||
assert_eq!(json["status"]["reused"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_reloaded_relays_hashes_and_changed_flag_to_wire() {
|
||||
let plugin_id = domain::PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let dto = DomainEventDto::from(&DomainEvent::PluginReloaded {
|
||||
plugin_id,
|
||||
old_content_hash: "abc123".to_owned(),
|
||||
new_content_hash: "def456".to_owned(),
|
||||
changed: true,
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "pluginReloaded");
|
||||
assert_eq!(json["pluginId"], "dev.acme.gitgraph");
|
||||
assert_eq!(json["oldContentHash"], "abc123");
|
||||
assert_eq!(json["newContentHash"], "def456");
|
||||
assert_eq!(json["changed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_status_changed_relays_downloading_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
||||
|
||||
@ -44,21 +44,22 @@ use application::{
|
||||
ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||
RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, RenameDevice, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities,
|
||||
ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks,
|
||||
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||
RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer,
|
||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
||||
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
||||
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput,
|
||||
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
|
||||
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
||||
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -124,6 +125,7 @@ pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLo
|
||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||
|
||||
use infrastructure::orchestrator::mcp::{PluginToolError, PluginToolProvider};
|
||||
use infrastructure::StdioTransport;
|
||||
|
||||
use interprocess::local_socket::tokio::Listener as LocalSocketListener;
|
||||
@ -143,6 +145,51 @@ fn spawn_detached(future: impl std::future::Future<Output = ()> + Send + 'static
|
||||
});
|
||||
}
|
||||
|
||||
struct AppPluginToolProvider {
|
||||
reload: Arc<ReloadPlugin>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PluginReloadToolArgs {
|
||||
plugin_id: String,
|
||||
expected_content_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginToolProvider for AppPluginToolProvider {
|
||||
async fn handle_plugin_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, PluginToolError> {
|
||||
if name != "idea_plugin_reload" {
|
||||
return Err(PluginToolError::new(
|
||||
"NOT_FOUND",
|
||||
format!("unknown plugin tool: {name}"),
|
||||
));
|
||||
}
|
||||
let args: PluginReloadToolArgs = serde_json::from_value(arguments)
|
||||
.map_err(|err| PluginToolError::new("INVALID", format!("invalid arguments: {err}")))?;
|
||||
let result = self
|
||||
.reload
|
||||
.execute(ReloadPluginInput {
|
||||
plugin_id: args.plugin_id,
|
||||
expected_content_hash: args.expected_content_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| PluginToolError::new(err.code(), err.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|err| {
|
||||
PluginToolError::new(
|
||||
"INTERNAL",
|
||||
format!("failed to serialize plugin reload result: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backend_core_build_tests {
|
||||
use super::BackendCore;
|
||||
@ -1224,6 +1271,8 @@ pub struct BackendCore {
|
||||
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
|
||||
/// MCP provider for the public `idea_managed_temp_*` tools.
|
||||
pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>,
|
||||
/// MCP provider for public plugin administration tools.
|
||||
pub(crate) plugin_tool_provider: Arc<dyn PluginToolProvider>,
|
||||
/// Open an ephemeral AI assistant chat bound to one ticket.
|
||||
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
||||
/// Close an ephemeral AI assistant chat bound to one ticket.
|
||||
@ -1324,6 +1373,8 @@ pub struct BackendCore {
|
||||
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
|
||||
/// Install a plugin directory snapshot.
|
||||
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
|
||||
/// Hot-reload an installed plugin from its recorded directory source.
|
||||
pub reload_plugin: Arc<ReloadPlugin>,
|
||||
/// List installed plugins for admin UI.
|
||||
pub list_plugins: Arc<ListPlugins>,
|
||||
/// Enable or disable a plugin.
|
||||
@ -2704,6 +2755,16 @@ impl BackendCore {
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let reload_plugin = Arc::new(ReloadPlugin::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let plugin_tool_provider: Arc<dyn PluginToolProvider> = Arc::new(AppPluginToolProvider {
|
||||
reload: Arc::clone(&reload_plugin),
|
||||
});
|
||||
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
@ -3147,6 +3208,7 @@ impl BackendCore {
|
||||
Arc::clone(&ticket_tool_provider),
|
||||
Arc::clone(&template_tool_provider),
|
||||
Arc::clone(&managed_temp_tool_provider),
|
||||
Arc::clone(&plugin_tool_provider),
|
||||
)) as Arc<dyn ToolInvoker>);
|
||||
|
||||
let stop_live_agent = Arc::new(
|
||||
@ -3364,6 +3426,7 @@ impl BackendCore {
|
||||
review_plugin_package,
|
||||
install_plugin_from_archive,
|
||||
install_plugin_from_directory,
|
||||
reload_plugin,
|
||||
list_plugins,
|
||||
set_plugin_enabled,
|
||||
uninstall_plugin,
|
||||
@ -3382,6 +3445,7 @@ impl BackendCore {
|
||||
ticket_tool_binder,
|
||||
template_tool_binder,
|
||||
managed_temp_tool_binder,
|
||||
plugin_tool_provider,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3474,6 +3538,7 @@ impl BackendCore {
|
||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
||||
.with_template_tools(Arc::clone(&self.template_tool_provider))
|
||||
.with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider))
|
||||
.with_plugin_tools(Arc::clone(&self.plugin_tool_provider))
|
||||
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
|
||||
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
||||
endpoint,
|
||||
|
||||
@ -14,6 +14,7 @@ use domain::ports::{
|
||||
ToolSpec,
|
||||
};
|
||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||
use infrastructure::orchestrator::mcp::PluginToolProvider;
|
||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
||||
use serde_json::Value;
|
||||
|
||||
@ -29,6 +30,7 @@ pub struct AppOpenAiToolInvoker {
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
plugin_tools: Arc<dyn PluginToolProvider>,
|
||||
}
|
||||
|
||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||
@ -103,6 +105,7 @@ impl AppOpenAiToolInvoker {
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
plugin_tools: Arc<dyn PluginToolProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
@ -112,6 +115,7 @@ impl AppOpenAiToolInvoker {
|
||||
ticket_tools,
|
||||
template_tools,
|
||||
managed_temp_tools,
|
||||
plugin_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,6 +236,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
ToolInvocationError::Execution(format!("JSON managed temp tool: {e}"))
|
||||
});
|
||||
}
|
||||
if infrastructure::orchestrator::mcp::tools::is_plugin_tool(name) {
|
||||
let value = self
|
||||
.plugin_tools
|
||||
.handle_plugin_tool(&project, &requester, name, value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let detail =
|
||||
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
|
||||
ToolInvocationError::Execution(detail)
|
||||
})?;
|
||||
return serde_json::to_string(&value)
|
||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON plugin tool: {e}")));
|
||||
}
|
||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||
.map_err(|e| match e {
|
||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||
@ -424,6 +441,7 @@ mod tests {
|
||||
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::PluginToolError;
|
||||
use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
@ -606,6 +624,36 @@ mod tests {
|
||||
Arc::new(FakeManagedTempTools::default())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePluginTools {
|
||||
calls: Mutex<Vec<(String, String, Value)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginToolProvider for FakePluginTools {
|
||||
async fn handle_plugin_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, PluginToolError> {
|
||||
self.calls.lock().unwrap().push((
|
||||
requester.to_owned(),
|
||||
name.to_owned(),
|
||||
arguments.clone(),
|
||||
));
|
||||
Ok(json!({
|
||||
"pluginId": arguments.get("pluginId").and_then(Value::as_str).unwrap_or_default(),
|
||||
"changed": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_plugin_tools() -> Arc<FakePluginTools> {
|
||||
Arc::new(FakePluginTools::default())
|
||||
}
|
||||
|
||||
fn issue_ref(raw: &str) -> IssueRef {
|
||||
IssueRef::from_str(raw).unwrap()
|
||||
}
|
||||
@ -772,6 +820,7 @@ mod tests {
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
let denied = invoker
|
||||
@ -838,6 +887,7 @@ mod tests {
|
||||
ticket_tools.clone(),
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
for tool in ["idea_ticket_list", "idea_template_list"] {
|
||||
@ -928,6 +978,7 @@ mod tests {
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
let tools = invoker
|
||||
@ -968,6 +1019,7 @@ mod tests {
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
let tools = invoker
|
||||
@ -1001,6 +1053,7 @@ mod tests {
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
@ -1048,6 +1101,7 @@ mod tests {
|
||||
ticket_tools,
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
fake_plugin_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
@ -1095,4 +1149,51 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_explicit_plugin_reload_permission_routes_to_plugin_provider() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"idea-openai-plugin-reload-permissions-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let core = crate::BackendCore::build(temp.clone());
|
||||
let agent = AgentId::from_uuid(Uuid::from_u128(271));
|
||||
let requester = agent.to_string();
|
||||
let plugin_tools = fake_plugin_tools();
|
||||
let invoker = AppOpenAiToolInvoker::new(
|
||||
Arc::clone(&core.orchestrator_service),
|
||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||
Arc::new(FakePolicies::default()),
|
||||
mcp_permissions(allow_doc(agent, &["idea_plugin_reload"])),
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
plugin_tools.clone(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
.call(
|
||||
"idea_plugin_reload",
|
||||
&json!({
|
||||
PROJECT_ROOT_ARG: "/tmp/project",
|
||||
REQUESTER_ARG: requester,
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"expectedContentHash": "abc123",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("explicit durable override should reach plugin provider");
|
||||
let result: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(result["pluginId"], "dev.acme.gitgraph");
|
||||
assert_eq!(result["changed"], true);
|
||||
|
||||
let calls = plugin_tools.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, requester);
|
||||
assert_eq!(calls[0].1, "idea_plugin_reload");
|
||||
assert_eq!(calls[0].2["expectedContentHash"], "abc123");
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +61,17 @@ pub enum DomainEvent {
|
||||
/// Installed version.
|
||||
version: PluginVersion,
|
||||
},
|
||||
/// An installed plugin package was reloaded from its recorded source.
|
||||
PluginReloaded {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Content hash before the reload.
|
||||
old_content_hash: String,
|
||||
/// Content hash after the reload.
|
||||
new_content_hash: String,
|
||||
/// Whether the package contents changed.
|
||||
changed: bool,
|
||||
},
|
||||
/// A plugin was enabled.
|
||||
PluginEnabled {
|
||||
/// Plugin id.
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
|
||||
pub mod jsonrpc;
|
||||
pub mod managed_temp;
|
||||
pub mod plugins;
|
||||
pub mod policy;
|
||||
pub mod server;
|
||||
pub mod templates;
|
||||
@ -41,6 +42,7 @@ pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
|
||||
pub use plugins::{PluginToolError, PluginToolProvider};
|
||||
pub use policy::ToolPolicyRegistry;
|
||||
pub use server::McpServer;
|
||||
pub use templates::{TemplateToolError, TemplateToolProvider};
|
||||
|
||||
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal file
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! Public MCP plugin administration tools.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::Project;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::tools::ToolDef;
|
||||
|
||||
/// Error returned by an MCP plugin tool provider.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{code}: {message}")]
|
||||
pub struct PluginToolError {
|
||||
/// Stable machine-readable code.
|
||||
pub code: &'static str,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl PluginToolError {
|
||||
/// Builds a typed plugin-tool error.
|
||||
#[must_use]
|
||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the error as a camelCase JSON object.
|
||||
#[must_use]
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({ "code": self.code, "message": self.message })
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider injected by the composition root to execute public plugin tools.
|
||||
#[async_trait]
|
||||
pub trait PluginToolProvider: Send + Sync {
|
||||
/// Executes one public plugin tool.
|
||||
async fn handle_plugin_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, PluginToolError>;
|
||||
}
|
||||
|
||||
/// Returns true when `name` is a public plugin tool.
|
||||
#[must_use]
|
||||
pub fn is_plugin_tool(name: &str) -> bool {
|
||||
name == "idea_plugin_reload"
|
||||
}
|
||||
|
||||
/// Public plugin tool definitions advertised by `tools/list`.
|
||||
#[must_use]
|
||||
pub fn catalogue() -> Vec<ToolDef> {
|
||||
vec![ToolDef {
|
||||
name: "idea_plugin_reload",
|
||||
description: "Hot-reload an installed directory plugin from its recorded source.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pluginId": { "type": "string" },
|
||||
"expectedContentHash": { "type": "string" }
|
||||
},
|
||||
"required": ["pluginId"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
}]
|
||||
}
|
||||
@ -35,6 +35,7 @@ use super::jsonrpc::{
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::managed_temp::ManagedTempToolProvider;
|
||||
use super::plugins::PluginToolProvider;
|
||||
use super::policy::ToolPolicyRegistry;
|
||||
use super::templates::TemplateToolProvider;
|
||||
use super::tickets::TicketToolProvider;
|
||||
@ -84,6 +85,8 @@ pub struct McpServer {
|
||||
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
|
||||
/// the provider maps those calls to application managed-temp use cases.
|
||||
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
||||
/// Optional public plugin administration provider.
|
||||
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||
@ -106,6 +109,7 @@ impl McpServer {
|
||||
ticket_tools: None,
|
||||
template_tools: None,
|
||||
managed_temp_tools: None,
|
||||
plugin_tools: None,
|
||||
tool_policies: None,
|
||||
mcp_tool_permissions: None,
|
||||
}
|
||||
@ -156,6 +160,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the public plugin administration provider.
|
||||
#[must_use]
|
||||
pub fn with_plugin_tools(mut self, plugin_tools: Arc<dyn PluginToolProvider>) -> Self {
|
||||
self.plugin_tools = Some(plugin_tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
@ -192,6 +203,7 @@ impl McpServer {
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
template_tools: self.template_tools.clone(),
|
||||
managed_temp_tools: self.managed_temp_tools.clone(),
|
||||
plugin_tools: self.plugin_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||
}
|
||||
@ -624,6 +636,32 @@ impl McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
if tools::is_plugin_tool(&name) {
|
||||
let result = match &self.plugin_tools {
|
||||
Some(provider) => {
|
||||
provider
|
||||
.handle_plugin_tool(&self.project, &self.requester, &name, arguments)
|
||||
.await
|
||||
}
|
||||
None => Err(super::plugins::PluginToolError::new(
|
||||
"UNAVAILABLE",
|
||||
"plugin tools are not configured",
|
||||
)),
|
||||
};
|
||||
self.publish_processed(&name, result.is_ok());
|
||||
return match result {
|
||||
Ok(value) => {
|
||||
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
Err(err) => {
|
||||
let text =
|
||||
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
||||
Ok(tool_result_text(&text, true))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
|
||||
@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_managed_temp_allocate",
|
||||
"idea_managed_temp_release",
|
||||
"idea_managed_temp_gc",
|
||||
"idea_plugin_reload",
|
||||
];
|
||||
|
||||
/// All MCP tool names that have an explicit access classification.
|
||||
@ -157,6 +158,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
) || is_ticket_tool(tool)
|
||||
|| is_template_tool(tool)
|
||||
|| is_managed_temp_tool(tool)
|
||||
|| is_plugin_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public ticket MCP tool.
|
||||
@ -177,6 +179,12 @@ pub fn is_managed_temp_tool(tool: &str) -> bool {
|
||||
super::managed_temp::is_managed_temp_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public plugin MCP tool.
|
||||
#[must_use]
|
||||
pub fn is_plugin_tool(tool: &str) -> bool {
|
||||
super::plugins::is_plugin_tool(tool)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
///
|
||||
/// Exactly the tools whose mapping target already exists as an
|
||||
@ -499,6 +507,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
tools.extend(super::tickets::catalogue());
|
||||
tools.extend(super::templates::catalogue());
|
||||
tools.extend(super::managed_temp::catalogue());
|
||||
tools.extend(super::plugins::catalogue());
|
||||
tools
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,8 @@ use application::{
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||
use infrastructure::orchestrator::mcp::{
|
||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||
PluginToolError, PluginToolProvider, TemplateToolError, TemplateToolProvider, TicketToolError,
|
||||
TicketToolProvider,
|
||||
};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
@ -631,6 +632,31 @@ struct FakeTemplateTools {
|
||||
mutation_attempts: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakePluginTools {
|
||||
calls: Arc<Mutex<Vec<(String, String, Value)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginToolProvider for FakePluginTools {
|
||||
async fn handle_plugin_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, PluginToolError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((requester.to_owned(), name.to_owned(), arguments.clone()));
|
||||
Ok(json!({
|
||||
"plugin": { "id": arguments["pluginId"] },
|
||||
"changed": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeTemplateTools {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
@ -774,6 +800,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
"idea_template_create",
|
||||
"idea_template_update",
|
||||
"idea_template_delete",
|
||||
// Public plugin administration tools.
|
||||
"idea_plugin_reload",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
@ -783,8 +811,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
assert!(!names.contains(&"idea_reply"));
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
47,
|
||||
"exactly the forty-seven exposed idea_* tools; got {names:?}"
|
||||
48,
|
||||
"exactly the forty-eight exposed idea_* tools; got {names:?}"
|
||||
);
|
||||
|
||||
// Every tool advertises an object input schema.
|
||||
@ -897,6 +925,7 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
|
||||
assert!(!names.contains(&"idea_template_create"));
|
||||
assert!(!names.contains(&"idea_template_update"));
|
||||
assert!(!names.contains(&"idea_template_delete"));
|
||||
assert!(!names.contains(&"idea_plugin_reload"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1006,6 +1035,43 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||
assert_eq!(template_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arguments() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let plugin_tools = Arc::new(FakePluginTools::default());
|
||||
let agent = AgentId::from_uuid(Uuid::from_u128(271));
|
||||
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_plugin_reload"]))
|
||||
.with_plugin_tools(plugin_tools.clone())
|
||||
.for_requester(agent.to_string());
|
||||
|
||||
let response = server
|
||||
.handle_raw(&tools_call(
|
||||
271,
|
||||
"idea_plugin_reload",
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"expectedContentHash": "abc123"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("reply owed");
|
||||
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("tool result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
|
||||
assert_eq!(payload["plugin"]["id"], json!("dev.acme.gitgraph"));
|
||||
let calls = plugin_tools.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, agent.to_string());
|
||||
assert_eq!(calls[0].1, "idea_plugin_reload");
|
||||
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
|
||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
|
||||
|
||||
Reference in New Issue
Block a user