merge(plugins): install_from_directory agent-facing + soft refresh UI des contributions après install/reload — #279 (QA verte ré-exécutée: application 2/2, mcp_server 39/39, plugin_install_load 5/5, frontend 35/35, typecheck; ticket #279 non fermé: rebuild AppImage restant; branche feature/279-plugin-soft-refresh)
This commit is contained in:
@ -2372,6 +2372,7 @@ impl InstallPluginFromArchive {
|
|||||||
self.validator.as_ref(),
|
self.validator.as_ref(),
|
||||||
self.events.as_ref(),
|
self.events.as_ref(),
|
||||||
self.mcp.as_ref(),
|
self.mcp.as_ref(),
|
||||||
|
None,
|
||||||
self.packages
|
self.packages
|
||||||
.install_from_archive(&LocalPath::new(path))
|
.install_from_archive(&LocalPath::new(path))
|
||||||
.await
|
.await
|
||||||
@ -2411,12 +2412,27 @@ impl InstallPluginFromDirectory {
|
|||||||
|
|
||||||
/// Executes the use case.
|
/// Executes the use case.
|
||||||
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
|
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
|
||||||
|
self.execute_with_expected_plugin_id(path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the use case and optionally requires the staged manifest to carry
|
||||||
|
/// a specific plugin id. The guard is evaluated before the package is committed.
|
||||||
|
pub async fn execute_with_expected_plugin_id(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
expected_plugin_id: Option<String>,
|
||||||
|
) -> Result<PluginInstallResult, AppError> {
|
||||||
|
let expected_plugin_id = expected_plugin_id
|
||||||
|
.map(PluginId::new)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
install_from_staged(
|
install_from_staged(
|
||||||
self.packages.as_ref(),
|
self.packages.as_ref(),
|
||||||
self.registry.as_ref(),
|
self.registry.as_ref(),
|
||||||
self.validator.as_ref(),
|
self.validator.as_ref(),
|
||||||
self.events.as_ref(),
|
self.events.as_ref(),
|
||||||
self.mcp.as_ref(),
|
self.mcp.as_ref(),
|
||||||
|
expected_plugin_id.as_ref(),
|
||||||
self.packages
|
self.packages
|
||||||
.install_from_directory(&LocalPath::new(path))
|
.install_from_directory(&LocalPath::new(path))
|
||||||
.await
|
.await
|
||||||
@ -2597,10 +2613,20 @@ async fn install_from_staged(
|
|||||||
validator: &dyn PluginManifestValidator,
|
validator: &dyn PluginManifestValidator,
|
||||||
events: &dyn EventBus,
|
events: &dyn EventBus,
|
||||||
mcp: &dyn PluginMcpSupervisor,
|
mcp: &dyn PluginMcpSupervisor,
|
||||||
|
expected_plugin_id: Option<&PluginId>,
|
||||||
staged: StagedPluginPackage,
|
staged: StagedPluginPackage,
|
||||||
) -> Result<PluginInstallResult, AppError> {
|
) -> Result<PluginInstallResult, AppError> {
|
||||||
let review = review_staged(packages, validator, &staged).await?;
|
let review = review_staged(packages, validator, &staged).await?;
|
||||||
let plugin_id = review.manifest.id.clone();
|
let plugin_id = review.manifest.id.clone();
|
||||||
|
if let Some(expected) = expected_plugin_id {
|
||||||
|
if expected != &plugin_id {
|
||||||
|
return Err(AppError::Invalid(format!(
|
||||||
|
"plugin manifest id `{}` does not match expected plugin `{}`",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
expected.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::diag!(
|
crate::diag!(
|
||||||
"[plugins] install staged reviewed plugin={} version={} hash={} source={}",
|
"[plugins] install staged reviewed plugin={} version={} hash={} source={}",
|
||||||
plugin_id.as_str(),
|
plugin_id.as_str(),
|
||||||
@ -2620,7 +2646,7 @@ async fn install_from_staged(
|
|||||||
source: review.source.clone(),
|
source: review.source.clone(),
|
||||||
content_hash: ContentHash::new(review.content_hash.clone())
|
content_hash: ContentHash::new(review.content_hash.clone())
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?,
|
.map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||||
restart_required: true,
|
restart_required: false,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
if let Err(err) = runtime_plugin_from_entry(packages, validator, entry.clone()).await {
|
if let Err(err) = runtime_plugin_from_entry(packages, validator, entry.clone()).await {
|
||||||
@ -2674,7 +2700,7 @@ async fn install_from_staged(
|
|||||||
Ok(PluginInstallResult {
|
Ok(PluginInstallResult {
|
||||||
plugin: admin,
|
plugin: admin,
|
||||||
review,
|
review,
|
||||||
restart_required: true,
|
restart_required: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3642,7 +3668,7 @@ mod tests {
|
|||||||
PluginStoreError, StoreError,
|
PluginStoreError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::{BackgroundTaskState, ProjectPath};
|
use domain::{BackgroundTaskState, PluginVersion, ProjectPath};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@ -4646,6 +4672,68 @@ mod tests {
|
|||||||
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn directory_install_is_soft_refreshable_and_publishes_installed_event() {
|
||||||
|
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||||
|
let registry = Arc::new(FakeRegistry::default());
|
||||||
|
let events = Arc::new(FakeEvents::default());
|
||||||
|
let install = InstallPluginFromDirectory::new(
|
||||||
|
packages,
|
||||||
|
registry.clone(),
|
||||||
|
Arc::new(validator()),
|
||||||
|
events.clone(),
|
||||||
|
Arc::new(FakeMcp::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.restart_required);
|
||||||
|
assert!(!result.plugin.restart_required);
|
||||||
|
assert!(
|
||||||
|
!registry
|
||||||
|
.load_registry()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.find(&plugin_id())
|
||||||
|
.unwrap()
|
||||||
|
.restart_required
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events.events.lock().unwrap().as_slice(),
|
||||||
|
&[DomainEvent::PluginInstalled {
|
||||||
|
plugin_id: plugin_id(),
|
||||||
|
version: PluginVersion::new("1.2.3").unwrap(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn directory_install_rejects_expected_plugin_id_mismatch_before_commit() {
|
||||||
|
let registry = Arc::new(FakeRegistry::default());
|
||||||
|
let events = Arc::new(FakeEvents::default());
|
||||||
|
let install = InstallPluginFromDirectory::new(
|
||||||
|
Arc::new(FakePackages::with_manifest(valid_manifest())),
|
||||||
|
registry.clone(),
|
||||||
|
Arc::new(validator()),
|
||||||
|
events.clone(),
|
||||||
|
Arc::new(FakeMcp::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = install
|
||||||
|
.execute_with_expected_plugin_id(
|
||||||
|
"/source/plugin".to_owned(),
|
||||||
|
Some("dev.acme.other".to_owned()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(error, AppError::Invalid(message) if message.contains("does not match expected plugin"))
|
||||||
|
);
|
||||||
|
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||||
|
assert!(events.events.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeProjectStore {
|
struct FakeProjectStore {
|
||||||
projects: Mutex<HashMap<ProjectId, Project>>,
|
projects: Mutex<HashMap<ProjectId, Project>>,
|
||||||
|
|||||||
@ -146,9 +146,17 @@ fn spawn_detached(future: impl std::future::Future<Output = ()> + Send + 'static
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppPluginToolProvider {
|
struct AppPluginToolProvider {
|
||||||
|
install_from_directory: Arc<InstallPluginFromDirectory>,
|
||||||
reload: Arc<ReloadPlugin>,
|
reload: Arc<ReloadPlugin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct PluginInstallFromDirectoryToolArgs {
|
||||||
|
path: String,
|
||||||
|
expected_plugin_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
struct PluginReloadToolArgs {
|
struct PluginReloadToolArgs {
|
||||||
@ -160,11 +168,32 @@ struct PluginReloadToolArgs {
|
|||||||
impl PluginToolProvider for AppPluginToolProvider {
|
impl PluginToolProvider for AppPluginToolProvider {
|
||||||
async fn handle_plugin_tool(
|
async fn handle_plugin_tool(
|
||||||
&self,
|
&self,
|
||||||
_project: &Project,
|
project: &Project,
|
||||||
_requester: &str,
|
_requester: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
arguments: Value,
|
arguments: Value,
|
||||||
) -> Result<Value, PluginToolError> {
|
) -> Result<Value, PluginToolError> {
|
||||||
|
if name == "idea_plugin_install_from_directory" {
|
||||||
|
let args: PluginInstallFromDirectoryToolArgs = serde_json::from_value(arguments)
|
||||||
|
.map_err(|err| {
|
||||||
|
PluginToolError::new("INVALID", format!("invalid arguments: {err}"))
|
||||||
|
})?;
|
||||||
|
let path = resolve_confined_plugin_directory(project, &args.path)?;
|
||||||
|
let result = self
|
||||||
|
.install_from_directory
|
||||||
|
.execute_with_expected_plugin_id(
|
||||||
|
path.to_string_lossy().into_owned(),
|
||||||
|
args.expected_plugin_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| PluginToolError::new(err.code(), err.to_string()))?;
|
||||||
|
return serde_json::to_value(result).map_err(|err| {
|
||||||
|
PluginToolError::new(
|
||||||
|
"INTERNAL",
|
||||||
|
format!("failed to serialize plugin install result: {err}"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
if name != "idea_plugin_reload" {
|
if name != "idea_plugin_reload" {
|
||||||
return Err(PluginToolError::new(
|
return Err(PluginToolError::new(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
@ -190,9 +219,63 @@ impl PluginToolProvider for AppPluginToolProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_confined_plugin_directory(
|
||||||
|
project: &Project,
|
||||||
|
requested: &str,
|
||||||
|
) -> Result<PathBuf, PluginToolError> {
|
||||||
|
if requested.trim().is_empty() {
|
||||||
|
return Err(PluginToolError::new("INVALID", "path must not be empty"));
|
||||||
|
}
|
||||||
|
let root = Path::new(project.root.as_str())
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| {
|
||||||
|
PluginToolError::new("INVALID", format!("project root cannot be resolved: {err}"))
|
||||||
|
})?;
|
||||||
|
let requested = Path::new(requested);
|
||||||
|
let candidate = if requested.is_absolute() {
|
||||||
|
requested.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(requested)
|
||||||
|
};
|
||||||
|
let canonical = candidate.canonicalize().map_err(|err| {
|
||||||
|
PluginToolError::new(
|
||||||
|
"INVALID",
|
||||||
|
format!("plugin directory cannot be resolved: {err}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !canonical.starts_with(&root) {
|
||||||
|
return Err(PluginToolError::new(
|
||||||
|
"INVALID",
|
||||||
|
"plugin directory must remain within the requester project root",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !canonical.is_dir() {
|
||||||
|
return Err(PluginToolError::new(
|
||||||
|
"INVALID",
|
||||||
|
"plugin source must be a directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod backend_core_build_tests {
|
mod backend_core_build_tests {
|
||||||
use super::BackendCore;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{resolve_confined_plugin_directory, BackendCore};
|
||||||
|
use domain::{Project, ProjectId, ProjectPath, RemoteRef};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn project(root: &Path) -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::new_v4()),
|
||||||
|
"plugin-tool-test",
|
||||||
|
ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_core_builds_without_desktop_runtime() {
|
fn backend_core_builds_without_desktop_runtime() {
|
||||||
@ -204,6 +287,68 @@ mod backend_core_build_tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_directory_resolution_accepts_relative_directory_under_project_root() {
|
||||||
|
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4()));
|
||||||
|
let plugin = root.join("plugins/example");
|
||||||
|
std::fs::create_dir_all(&plugin).unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_confined_plugin_directory(&project(&root), "plugins/example")
|
||||||
|
.expect("relative directory should resolve");
|
||||||
|
|
||||||
|
assert_eq!(resolved, plugin.canonicalize().unwrap());
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_directory_resolution_rejects_absolute_path_outside_project_root() {
|
||||||
|
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4()));
|
||||||
|
let outside = std::env::temp_dir().join(format!("idea-plugin-outside-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
std::fs::create_dir_all(&outside).unwrap();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
resolve_confined_plugin_directory(&project(&root), outside.to_string_lossy().as_ref())
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code, "INVALID");
|
||||||
|
assert!(error.message.contains("within the requester project root"));
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
let _ = std::fs::remove_dir_all(outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_directory_resolution_rejects_file_sources() {
|
||||||
|
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
std::fs::write(root.join("plugin.zip"), b"not a directory").unwrap();
|
||||||
|
|
||||||
|
let error = resolve_confined_plugin_directory(&project(&root), "plugin.zip").unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code, "INVALID");
|
||||||
|
assert!(error.message.contains("must be a directory"));
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn plugin_directory_resolution_rejects_symlink_escaping_project_root() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4()));
|
||||||
|
let outside = std::env::temp_dir().join(format!("idea-plugin-outside-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
std::fs::create_dir_all(&outside).unwrap();
|
||||||
|
symlink(&outside, root.join("escape")).unwrap();
|
||||||
|
|
||||||
|
let error = resolve_confined_plugin_directory(&project(&root), "escape").unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code, "INVALID");
|
||||||
|
assert!(error.message.contains("within the requester project root"));
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
let _ = std::fs::remove_dir_all(outside);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@ -2769,6 +2914,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&plugin_mcp_supervisor_port),
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
));
|
));
|
||||||
let plugin_tool_provider: Arc<dyn PluginToolProvider> = Arc::new(AppPluginToolProvider {
|
let plugin_tool_provider: Arc<dyn PluginToolProvider> = Arc::new(AppPluginToolProvider {
|
||||||
|
install_from_directory: Arc::clone(&install_plugin_from_directory),
|
||||||
reload: Arc::clone(&reload_plugin),
|
reload: Arc::clone(&reload_plugin),
|
||||||
});
|
});
|
||||||
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
||||||
|
|||||||
@ -49,23 +49,41 @@ pub trait PluginToolProvider: Send + Sync {
|
|||||||
/// Returns true when `name` is a public plugin tool.
|
/// Returns true when `name` is a public plugin tool.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_plugin_tool(name: &str) -> bool {
|
pub fn is_plugin_tool(name: &str) -> bool {
|
||||||
name == "idea_plugin_reload"
|
matches!(
|
||||||
|
name,
|
||||||
|
"idea_plugin_install_from_directory" | "idea_plugin_reload"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public plugin tool definitions advertised by `tools/list`.
|
/// Public plugin tool definitions advertised by `tools/list`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn catalogue() -> Vec<ToolDef> {
|
pub fn catalogue() -> Vec<ToolDef> {
|
||||||
vec![ToolDef {
|
vec![
|
||||||
name: "idea_plugin_reload",
|
ToolDef {
|
||||||
description: "Hot-reload an installed directory plugin from its recorded source.",
|
name: "idea_plugin_install_from_directory",
|
||||||
input_schema: json!({
|
description: "Install a plugin from a directory confined to the requester project root; the emitted event lets the UI refresh plugin contributions without restarting IdeA.",
|
||||||
"type": "object",
|
input_schema: json!({
|
||||||
"properties": {
|
"type": "object",
|
||||||
"pluginId": { "type": "string" },
|
"properties": {
|
||||||
"expectedContentHash": { "type": "string" }
|
"path": { "type": "string" },
|
||||||
},
|
"expectedPluginId": { "type": "string" }
|
||||||
"required": ["pluginId"],
|
},
|
||||||
"additionalProperties": false
|
"required": ["path"],
|
||||||
}),
|
"additionalProperties": false
|
||||||
}]
|
}),
|
||||||
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_plugin_reload",
|
||||||
|
description: "Hot-reload an installed directory plugin from its recorded source; the emitted event lets the UI refresh plugin contributions.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pluginId": { "type": "string" },
|
||||||
|
"expectedContentHash": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["pluginId"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_managed_temp_allocate",
|
"idea_managed_temp_allocate",
|
||||||
"idea_managed_temp_release",
|
"idea_managed_temp_release",
|
||||||
"idea_managed_temp_gc",
|
"idea_managed_temp_gc",
|
||||||
|
"idea_plugin_install_from_directory",
|
||||||
"idea_plugin_reload",
|
"idea_plugin_reload",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -801,6 +801,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
"idea_template_update",
|
"idea_template_update",
|
||||||
"idea_template_delete",
|
"idea_template_delete",
|
||||||
// Public plugin administration tools.
|
// Public plugin administration tools.
|
||||||
|
"idea_plugin_install_from_directory",
|
||||||
"idea_plugin_reload",
|
"idea_plugin_reload",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
@ -811,8 +812,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
48,
|
49,
|
||||||
"exactly the forty-eight exposed idea_* tools; got {names:?}"
|
"exactly the forty-nine exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// Every tool advertises an object input schema.
|
||||||
@ -925,6 +926,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_create"));
|
||||||
assert!(!names.contains(&"idea_template_update"));
|
assert!(!names.contains(&"idea_template_update"));
|
||||||
assert!(!names.contains(&"idea_template_delete"));
|
assert!(!names.contains(&"idea_template_delete"));
|
||||||
|
assert!(!names.contains(&"idea_plugin_install_from_directory"));
|
||||||
assert!(!names.contains(&"idea_plugin_reload"));
|
assert!(!names.contains(&"idea_plugin_reload"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -939,10 +941,12 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
let template_tools = Arc::new(FakeTemplateTools::default());
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
|
let plugin_tools = Arc::new(FakePluginTools::default());
|
||||||
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
|
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
|
||||||
.with_ticket_tools(ticket_tools.clone())
|
.with_ticket_tools(ticket_tools.clone())
|
||||||
.with_template_tools(template_tools.clone())
|
.with_template_tools(template_tools.clone())
|
||||||
|
.with_plugin_tools(plugin_tools.clone())
|
||||||
.for_requester(agent.to_string());
|
.for_requester(agent.to_string());
|
||||||
|
|
||||||
for (id, tool, arguments) in [
|
for (id, tool, arguments) in [
|
||||||
@ -1003,6 +1007,16 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
"idea_template_delete",
|
"idea_template_delete",
|
||||||
json!({ "templateId": Uuid::new_v4() }),
|
json!({ "templateId": Uuid::new_v4() }),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
118,
|
||||||
|
"idea_plugin_install_from_directory",
|
||||||
|
json!({ "path": "plugins/example" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
119,
|
||||||
|
"idea_plugin_reload",
|
||||||
|
json!({ "pluginId": "dev.acme.gitgraph" }),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let response = server
|
let response = server
|
||||||
.handle_raw(&tools_call(id, tool, arguments))
|
.handle_raw(&tools_call(id, tool, arguments))
|
||||||
@ -1033,6 +1047,10 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
"denied template mutations must not reach the provider"
|
"denied template mutations must not reach the provider"
|
||||||
);
|
);
|
||||||
assert_eq!(template_tools.mutation_attempts(), 0);
|
assert_eq!(template_tools.mutation_attempts(), 0);
|
||||||
|
assert!(
|
||||||
|
plugin_tools.calls.lock().unwrap().is_empty(),
|
||||||
|
"denied plugin writes must not reach the provider"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -1072,6 +1090,45 @@ async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arg
|
|||||||
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
|
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicitly_allowed_plugin_install_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(279));
|
||||||
|
let server = server_with_mcp_permissions(
|
||||||
|
service,
|
||||||
|
allow_doc(agent, &["idea_plugin_install_from_directory"]),
|
||||||
|
)
|
||||||
|
.with_plugin_tools(plugin_tools.clone())
|
||||||
|
.for_requester(agent.to_string());
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.handle_raw(&tools_call(
|
||||||
|
279,
|
||||||
|
"idea_plugin_install_from_directory",
|
||||||
|
json!({
|
||||||
|
"path": "plugins/example",
|
||||||
|
"expectedPluginId": "dev.acme.gitgraph"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.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 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_install_from_directory");
|
||||||
|
assert_eq!(calls[0].2["path"], json!("plugins/example"));
|
||||||
|
assert_eq!(calls[0].2["expectedPluginId"], json!("dev.acme.gitgraph"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
|
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
|
||||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
|
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
|
||||||
|
|||||||
@ -107,7 +107,8 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
|||||||
assert_eq!(result.plugin.display_name, "Reference Minimal Plugin");
|
assert_eq!(result.plugin.display_name, "Reference Minimal Plugin");
|
||||||
assert_eq!(result.review.contribution_summary.top_level_menus, 1);
|
assert_eq!(result.review.contribution_summary.top_level_menus, 1);
|
||||||
assert_eq!(result.review.contribution_summary.menu_items, 1);
|
assert_eq!(result.review.contribution_summary.menu_items, 1);
|
||||||
assert!(result.restart_required);
|
assert!(!result.restart_required);
|
||||||
|
assert!(!result.plugin.restart_required);
|
||||||
assert!(app_data
|
assert!(app_data
|
||||||
.join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json")
|
.join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json")
|
||||||
.is_file());
|
.is_file());
|
||||||
|
|||||||
@ -4051,12 +4051,12 @@ export class MockPluginGateway implements PluginGateway {
|
|||||||
lifecycleState: "enabled",
|
lifecycleState: "enabled",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
pendingUninstall: false,
|
pendingUninstall: false,
|
||||||
restartRequired: true,
|
restartRequired: false,
|
||||||
trustLevel: "full",
|
trustLevel: "full",
|
||||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||||
};
|
};
|
||||||
this.plugins.push(plugin);
|
this.plugins.push(plugin);
|
||||||
return { plugin: structuredClone(plugin), restartRequired: true };
|
return { plugin: structuredClone(plugin), restartRequired: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||||
|
|||||||
@ -282,6 +282,14 @@ export interface EmbeddedServerStatus {
|
|||||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
|
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||||
|
| {
|
||||||
|
type: "pluginReloaded";
|
||||||
|
pluginId: string;
|
||||||
|
oldContentHash: string;
|
||||||
|
newContentHash: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||||
| { type: "agentExited"; agentId: string; code: number }
|
| { type: "agentExited"; agentId: string; code: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
182
frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
Normal file
182
frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import type {
|
||||||
|
CustomPluginLayoutCell,
|
||||||
|
PluginRuntimeContributionCatalog,
|
||||||
|
PluginRuntimePlugin,
|
||||||
|
} from "@/domain";
|
||||||
|
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||||
|
import { PluginLayoutCellView } from "./PluginLayoutCellView";
|
||||||
|
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
|
||||||
|
|
||||||
|
function dataUrl(source: string): string {
|
||||||
|
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRuntimePlugin {
|
||||||
|
return {
|
||||||
|
id: "dev.acme.refreshable",
|
||||||
|
displayName: "Refreshable v1",
|
||||||
|
version: "1.0.0",
|
||||||
|
bundleUrl: dataUrl("export function activate() {}"),
|
||||||
|
contentHash: "v1",
|
||||||
|
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MutableRuntimeCatalogGateway extends MockPluginGateway {
|
||||||
|
calls = 0;
|
||||||
|
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
|
||||||
|
|
||||||
|
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
this.calls += 1;
|
||||||
|
return this.catalog;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeProbe({ onMount }: { onMount: () => void }) {
|
||||||
|
const { registry, loading } = usePluginRuntime();
|
||||||
|
const [workspaceState, setWorkspaceState] = useState("initial");
|
||||||
|
|
||||||
|
useEffect(onMount, [onMount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="runtime-plugin">
|
||||||
|
{loading ? "loading" : (registry.list()[0]?.displayName ?? "none")}
|
||||||
|
</span>
|
||||||
|
<span data-testid="workspace-state">{workspaceState}</span>
|
||||||
|
<button onClick={() => setWorkspaceState("preserved")}>change workspace</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||||
|
it("reloads contributions after install and reload events without remounting its workspace", async () => {
|
||||||
|
const plugin = new MutableRuntimeCatalogGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
const gateways = { ...createMockGateways(), plugin, system };
|
||||||
|
let mounts = 0;
|
||||||
|
const onMount = () => {
|
||||||
|
mounts += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
|
<RuntimeProbe onMount={onMount} />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("none")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "change workspace" }));
|
||||||
|
|
||||||
|
plugin.catalog = { plugins: [runtimePlugin()] };
|
||||||
|
system.emit({
|
||||||
|
type: "pluginInstalled",
|
||||||
|
pluginId: "dev.acme.refreshable",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Refreshable v1")).toBeTruthy();
|
||||||
|
|
||||||
|
plugin.catalog = {
|
||||||
|
plugins: [
|
||||||
|
runtimePlugin({
|
||||||
|
displayName: "Refreshable v2",
|
||||||
|
version: "2.0.0",
|
||||||
|
contentHash: "v2",
|
||||||
|
bundleUrl: dataUrl("export function activate() { return { dispose() {} }; }"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
system.emit({
|
||||||
|
type: "pluginReloaded",
|
||||||
|
pluginId: "dev.acme.refreshable",
|
||||||
|
oldContentHash: "v1",
|
||||||
|
newContentHash: "v2",
|
||||||
|
changed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Refreshable v2")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||||
|
expect(mounts).toBe(1);
|
||||||
|
expect(plugin.calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => {
|
||||||
|
const plugin = new MutableRuntimeCatalogGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
const gateways = { ...createMockGateways(), plugin, system };
|
||||||
|
const testCell: CustomPluginLayoutCell = {
|
||||||
|
id: "plugin-cell-1",
|
||||||
|
pluginId: "dev.acme.refreshable",
|
||||||
|
layoutType: "dev.acme.refreshable.layout",
|
||||||
|
state: { tab: "history" },
|
||||||
|
};
|
||||||
|
plugin.catalog = {
|
||||||
|
plugins: [
|
||||||
|
runtimePlugin({
|
||||||
|
bundleUrl: dataUrl(`
|
||||||
|
export function activate(ctx) {
|
||||||
|
ctx.layouts.register({
|
||||||
|
type: "dev.acme.refreshable.layout",
|
||||||
|
component: () => "Plugin layout"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
contributes: {
|
||||||
|
menus: [],
|
||||||
|
menuItems: [],
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
type: "dev.acme.refreshable.layout",
|
||||||
|
label: "Refreshable layout",
|
||||||
|
component: "RefreshableLayout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mcpServers: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
|
<PluginLayoutCellView
|
||||||
|
projectId="project-1"
|
||||||
|
cell={testCell}
|
||||||
|
onStateChange={() => {}}
|
||||||
|
onOpenPlugins={() => {}}
|
||||||
|
onChooseAnotherLayout={() => {}}
|
||||||
|
/>
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Plugin layout")).toBeTruthy();
|
||||||
|
plugin.catalog = { plugins: [] };
|
||||||
|
system.emit({
|
||||||
|
type: "pluginReloaded",
|
||||||
|
pluginId: testCell.pluginId,
|
||||||
|
oldContentHash: "v1",
|
||||||
|
newContentHash: "v2",
|
||||||
|
changed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Layout indisponible")).toBeTruthy();
|
||||||
|
expect(testCell).toEqual({
|
||||||
|
id: "plugin-cell-1",
|
||||||
|
pluginId: "dev.acme.refreshable",
|
||||||
|
layoutType: "dev.acme.refreshable.layout",
|
||||||
|
state: { tab: "history" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(plugin.calls).toBe(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session
|
* `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window.
|
||||||
* (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered
|
* It fetches the runtime catalog (already filtered
|
||||||
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
|
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
|
||||||
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
||||||
* to the rest of the tree (menus in F3, layouts in F4).
|
* to the rest of the tree (menus in F3, layouts in F4).
|
||||||
*
|
*
|
||||||
* Mounted once near the app root, inside `<DIProvider>` (it reads gateways via
|
* Mounted near the window root, inside `<DIProvider>` (it reads gateways via
|
||||||
* `useGateways()`). Never re-runs within a session — carnet §1.3: disable/
|
* `useGateways()`). Install/reload domain events rebuild only this context value:
|
||||||
* uninstall only mask contributions in-session; the actual reload happens on
|
* the workspace below stays mounted and retains all non-plugin UI/runtime state.
|
||||||
* the next app start (a fresh mount of this provider).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
@ -73,6 +72,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (injected) return;
|
if (injected) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let refreshRevision = 0;
|
||||||
const pluginGateways = {
|
const pluginGateways = {
|
||||||
project: gateways.project,
|
project: gateways.project,
|
||||||
git: gateways.git,
|
git: gateways.git,
|
||||||
@ -89,89 +89,78 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
pluginConfig: gateways.pluginConfig,
|
pluginConfig: gateways.pluginConfig,
|
||||||
pluginStorage: gateways.pluginStorage,
|
pluginStorage: gateways.pluginStorage,
|
||||||
};
|
};
|
||||||
let activatedProjectScoped = false;
|
|
||||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
||||||
let unsubscribeFocus: Unsubscribe | undefined;
|
let unsubscribeFocus: Unsubscribe | undefined;
|
||||||
|
let unsubscribeDomainEvents: Unsubscribe | undefined;
|
||||||
|
let activeRegistry = value.registry;
|
||||||
|
|
||||||
void gateways.focusedProject
|
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
|
||||||
.onFocusedProjectChanged(async (project) => {
|
await Promise.allSettled(
|
||||||
if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return;
|
registry.list().map((plugin) => registry.remove(plugin.pluginId)),
|
||||||
activatedProjectScoped = true;
|
);
|
||||||
const projectPlugins = pendingProjectPlugins;
|
}
|
||||||
pendingProjectPlugins = [];
|
|
||||||
try {
|
|
||||||
const result = await loadPlugins(projectPlugins, pluginGateways);
|
|
||||||
if (cancelled) return;
|
|
||||||
setValue((prev) => {
|
|
||||||
for (const plugin of result.registry.list()) prev.registry.add(plugin);
|
|
||||||
return {
|
|
||||||
registry: prev.registry,
|
|
||||||
failures: [...prev.failures, ...result.failures],
|
|
||||||
pending: prev.pending.filter(
|
|
||||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
|
||||||
),
|
|
||||||
loading: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setValue((prev) => ({
|
|
||||||
...prev,
|
|
||||||
failures: [
|
|
||||||
...prev.failures,
|
|
||||||
...projectPlugins.map((plugin) => ({
|
|
||||||
pluginId: plugin.id,
|
|
||||||
reason: describeError(e),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
pending: prev.pending.filter(
|
|
||||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
|
||||||
),
|
|
||||||
loading: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((unsubscribe) => {
|
|
||||||
if (cancelled) unsubscribe();
|
|
||||||
else unsubscribeFocus = unsubscribe;
|
|
||||||
});
|
|
||||||
|
|
||||||
gateways.plugin
|
async function refreshRuntime(initial: boolean): Promise<void> {
|
||||||
.listRuntimeContributions()
|
const revision = ++refreshRevision;
|
||||||
.then(async (catalog) => {
|
try {
|
||||||
|
const catalog = await gateways.plugin.listRuntimeContributions();
|
||||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||||
|
if (cancelled || revision !== refreshRevision) {
|
||||||
|
await disposeRegistry(result.registry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
||||||
result.pending.some((p) => p.pluginId === entry.id),
|
result.pending.some((pending) => pending.pluginId === entry.id),
|
||||||
);
|
);
|
||||||
return result;
|
const previousRegistry = activeRegistry;
|
||||||
})
|
activeRegistry = result.registry;
|
||||||
.then((result) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setValue({
|
setValue({
|
||||||
registry: result.registry,
|
registry: result.registry,
|
||||||
failures: result.failures,
|
failures: result.failures,
|
||||||
pending: result.pending,
|
pending: result.pending,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
if (previousRegistry !== result.registry) void disposeRegistry(previousRegistry);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (cancelled || revision !== refreshRevision) return;
|
||||||
|
const failure = { pluginId: "<runtime-catalog>", reason: describeError(e) };
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
failures: initial ? [failure] : [...prev.failures, failure],
|
||||||
|
pending: initial ? [] : prev.pending,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void gateways.focusedProject
|
||||||
|
.onFocusedProjectChanged((project) => {
|
||||||
|
if (!project || pendingProjectPlugins.length === 0) return;
|
||||||
|
void refreshRuntime(false);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.then((unsubscribe) => {
|
||||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
if (cancelled) unsubscribe();
|
||||||
// rather than blocking the app (full-trust plugins are additive).
|
else unsubscribeFocus = unsubscribe;
|
||||||
if (!cancelled) {
|
|
||||||
setValue((prev) => ({
|
|
||||||
...prev,
|
|
||||||
failures: [
|
|
||||||
...prev.failures,
|
|
||||||
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
|
|
||||||
],
|
|
||||||
pending: [],
|
|
||||||
loading: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void gateways.system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
||||||
|
void refreshRuntime(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((unsubscribe) => {
|
||||||
|
if (cancelled) unsubscribe();
|
||||||
|
else unsubscribeDomainEvents = unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
void refreshRuntime(true);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
refreshRevision += 1;
|
||||||
unsubscribeFocus?.();
|
unsubscribeFocus?.();
|
||||||
|
unsubscribeDomainEvents?.();
|
||||||
|
void disposeRegistry(activeRegistry);
|
||||||
};
|
};
|
||||||
// Gateways are a stable singleton for the app session (from `useGateways`);
|
// Gateways are a stable singleton for the app session (from `useGateways`);
|
||||||
// re-running on every render would reload every plugin bundle.
|
// re-running on every render would reload every plugin bundle.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
|
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
|
||||||
* from archive/directory with pre-install review, full-trust mention visible,
|
* from archive/directory with pre-install review, full-trust mention visible,
|
||||||
* enable/disable/uninstall flows, and `restartRequired` rendered.
|
* enable/disable/uninstall flows, and lifecycle feedback rendered.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||||
@ -327,9 +327,9 @@ describe("PluginsPanel", () => {
|
|||||||
|
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
|
||||||
|
|
||||||
// The installed plugin now shows in the list, restart required (carnet §1.4).
|
// Install is live: the plugin is immediately active without a restart notice.
|
||||||
expect(await screen.findByText("mock-plugin")).toBeTruthy();
|
expect(await screen.findByText("mock-plugin")).toBeTruthy();
|
||||||
expect(screen.getByText("Redémarrage requis")).toBeTruthy();
|
expect(screen.queryByText("Redémarrage requis")).toBeNull();
|
||||||
expect(screen.getByText("Activé")).toBeTruthy();
|
expect(screen.getByText("Activé")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
|
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
|
||||||
*
|
*
|
||||||
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
* At UI bootstrap and after an install/reload event, the app calls
|
||||||
|
* {@link loadPlugins} with the catalog
|
||||||
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
||||||
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
||||||
* used to build the public service facade. For each entry it dynamically imports
|
* used to build the public service facade. For each entry it dynamically imports
|
||||||
@ -370,9 +371,8 @@ function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): Plug
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
||||||
* Called once at app bootstrap (and never again in the same session — carnet
|
* The provider swaps this fresh snapshot atomically at bootstrap and after a
|
||||||
* §1.3: disable/uninstall only masks contributions in-session, the actual
|
* supported plugin lifecycle event.
|
||||||
* unload happens on next restart via a fresh `loadPlugins` call).
|
|
||||||
*/
|
*/
|
||||||
export async function loadPlugins(
|
export async function loadPlugins(
|
||||||
catalogPlugins: PluginRuntimePlugin[],
|
catalogPlugins: PluginRuntimePlugin[],
|
||||||
|
|||||||
Reference in New Issue
Block a user