feat(backend): système de plugins — domaine, application, infrastructure (#43)
Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP), port et registre applicatif, chargement/validation en infrastructure, exposition DTO et commandes Tauri. Tests cargo verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1971,6 +1971,7 @@ dependencies = [
|
|||||||
"fastembed",
|
"fastembed",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"git2",
|
"git2",
|
||||||
|
"hex",
|
||||||
"landlock",
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
@ -1978,6 +1979,7 @@ dependencies = [
|
|||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@ -26,6 +26,7 @@ hex = "0.4"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
subtle = "2"
|
subtle = "2"
|
||||||
getrandom = "0.3"
|
getrandom = "0.3"
|
||||||
|
http = "1"
|
||||||
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
||||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
|
|||||||
@ -32,12 +32,12 @@ tauri-plugin-dialog = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
http = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bytes = "1.11"
|
bytes = "1.11"
|
||||||
cookie = "0.18"
|
cookie = "0.18"
|
||||||
http = "1.4"
|
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@ -21,6 +21,7 @@ pub mod events;
|
|||||||
pub mod mcp_bridge;
|
pub mod mcp_bridge;
|
||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
pub mod openai_tools;
|
pub mod openai_tools;
|
||||||
|
pub mod plugins;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
@ -133,6 +134,9 @@ pub fn dispatch() -> ExitCode {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
|
||||||
|
plugins::plugin_asset_protocol(ctx.app_handle(), request)
|
||||||
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||||
// and build the composition root once the app handle exists, so the
|
// and build the composition root once the app handle exists, so the
|
||||||
@ -154,6 +158,7 @@ pub fn run() {
|
|||||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
let embedded_server = Arc::clone(&app_state.embedded_server);
|
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||||
|
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
|
||||||
let core = app_state.core();
|
let core = app_state.core();
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@ -165,6 +170,11 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = plugin_mcp_reconcile.execute().await {
|
||||||
|
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||||
// independent of the per-view (navigation/layout) lifecycle — those
|
// independent of the per-view (navigation/layout) lifecycle — those
|
||||||
@ -334,6 +344,14 @@ pub fn run() {
|
|||||||
commands::cancel_background_task,
|
commands::cancel_background_task,
|
||||||
commands::retry_background_task,
|
commands::retry_background_task,
|
||||||
commands::list_background_tasks,
|
commands::list_background_tasks,
|
||||||
|
plugins::plugin_list_plugins,
|
||||||
|
plugins::plugin_review_package,
|
||||||
|
plugins::plugin_install_from_archive,
|
||||||
|
plugins::plugin_install_from_directory,
|
||||||
|
plugins::plugin_set_enabled,
|
||||||
|
plugins::plugin_uninstall,
|
||||||
|
plugins::plugin_list_runtime_contributions,
|
||||||
|
plugins::plugin_open_plugins_folder,
|
||||||
commands::get_server_exposure_settings,
|
commands::get_server_exposure_settings,
|
||||||
commands::save_server_exposure_settings,
|
commands::save_server_exposure_settings,
|
||||||
commands::preview_server_exposure_settings,
|
commands::preview_server_exposure_settings,
|
||||||
|
|||||||
293
crates/app-tauri/src/plugins.rs
Normal file
293
crates/app-tauri/src/plugins.rs
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
//! Plugin Tauri commands and asset protocol.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
||||||
|
use backend::dto::{
|
||||||
|
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
|
||||||
|
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
|
||||||
|
};
|
||||||
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
|
use domain::{PluginId, RelativePath};
|
||||||
|
use http::{header, Response, StatusCode};
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Lists installed plugins for the admin surface.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_list_plugins(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
|
||||||
|
state
|
||||||
|
.list_plugins
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reviews a local plugin package without committing it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_review_package(
|
||||||
|
input: ReviewPluginPackageDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginReviewDto, ErrorDto> {
|
||||||
|
let input = match input.source_kind.as_str() {
|
||||||
|
"archive" => ReviewPluginPackageInput::Archive { path: input.path },
|
||||||
|
"directory" => ReviewPluginPackageInput::Directory { path: input.path },
|
||||||
|
other => {
|
||||||
|
return Err(ErrorDto::invalid(format!(
|
||||||
|
"invalid plugin source kind: {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.review_plugin_package
|
||||||
|
.execute(input)
|
||||||
|
.await
|
||||||
|
.map(PluginReviewDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a plugin from a local archive.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_install_from_archive(
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.install_plugin_from_archive
|
||||||
|
.execute(path)
|
||||||
|
.await
|
||||||
|
.map(PluginInstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a plugin from a local directory snapshot.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_install_from_directory(
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.install_plugin_from_directory
|
||||||
|
.execute(path)
|
||||||
|
.await
|
||||||
|
.map(PluginInstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables a plugin.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_set_enabled(
|
||||||
|
plugin_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginAdminDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.set_plugin_enabled
|
||||||
|
.execute(SetPluginEnabledInput { plugin_id, enabled })
|
||||||
|
.await
|
||||||
|
.map(PluginAdminDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstalls a plugin.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_uninstall(
|
||||||
|
plugin_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginUninstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.uninstall_plugin
|
||||||
|
.execute(UninstallPluginInput { plugin_id })
|
||||||
|
.await
|
||||||
|
.map(PluginUninstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists active runtime contributions for frontend bootstrap.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_list_runtime_contributions(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.list_plugin_runtime_contributions
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map(PluginRuntimeContributionCatalogDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugin_open_plugins_folder(
|
||||||
|
plugin_id: Option<String>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let mut path = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| ErrorDto::invalid(e.to_string()))?
|
||||||
|
.join("plugins");
|
||||||
|
if let Some(id) = plugin_id {
|
||||||
|
let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
|
||||||
|
path = path.join("installed").join(id.as_str());
|
||||||
|
}
|
||||||
|
open_folder(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
|
||||||
|
/// checking registry state, hash and root confinement.
|
||||||
|
pub fn plugin_asset_protocol(
|
||||||
|
app: &AppHandle,
|
||||||
|
request: http::Request<Vec<u8>>,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
match plugin_asset_response(app, request) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err((status, message)) => Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(message.into_bytes())
|
||||||
|
.expect("valid protocol error response"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_asset_response(
|
||||||
|
app: &AppHandle,
|
||||||
|
request: http::Request<Vec<u8>>,
|
||||||
|
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||||
|
let uri = request.uri();
|
||||||
|
let plugin_id = uri
|
||||||
|
.host()
|
||||||
|
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
|
||||||
|
.and_then(|raw| {
|
||||||
|
PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||||
|
})?;
|
||||||
|
let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
|
||||||
|
let version_segment = parts.next().unwrap_or_default();
|
||||||
|
let hash = parts.next().unwrap_or_default();
|
||||||
|
let rel = parts.next().unwrap_or_default();
|
||||||
|
if version_segment.is_empty() || hash.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid plugin asset URL".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rel =
|
||||||
|
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let allowed = tauri::async_runtime::block_on(asset_allowed(
|
||||||
|
&plugin_id,
|
||||||
|
hash,
|
||||||
|
&rel,
|
||||||
|
state.plugin_registry_store.as_ref(),
|
||||||
|
state.plugin_package_store.as_ref(),
|
||||||
|
state.plugin_manifest_validator.as_ref(),
|
||||||
|
))?;
|
||||||
|
if !allowed {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"plugin asset is not active".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let root = app_data
|
||||||
|
.join("plugins")
|
||||||
|
.join("installed")
|
||||||
|
.join(plugin_id.as_str());
|
||||||
|
let target = root.join(rel.as_str());
|
||||||
|
let root = root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
let target = target
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
if !target.starts_with(&root) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"asset escapes plugin root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, content_type(&target))
|
||||||
|
.body(bytes)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn asset_allowed(
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
hash: &str,
|
||||||
|
rel: &RelativePath,
|
||||||
|
registry_store: &dyn PluginRegistryStore,
|
||||||
|
package_store: &dyn PluginPackageStore,
|
||||||
|
validator: &dyn PluginManifestValidator,
|
||||||
|
) -> Result<bool, (StatusCode, String)> {
|
||||||
|
let registry = registry_store
|
||||||
|
.load_registry()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let Some(entry) = registry.find(plugin_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let package = domain::PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id.clone()),
|
||||||
|
root: plugin_id.as_str().to_owned(),
|
||||||
|
};
|
||||||
|
let manifest_bytes = package_store
|
||||||
|
.read_manifest(&package)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
let manifest = validator
|
||||||
|
.validate(&manifest_bytes.bytes, &package)
|
||||||
|
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||||
|
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
||||||
|
let declared_main = manifest.main == *rel;
|
||||||
|
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||||
|
"json" => "application/json; charset=utf-8",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"css" => "text/css; charset=utf-8",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||||
|
let mut command = if cfg!(target_os = "macos") {
|
||||||
|
let mut c = std::process::Command::new("open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
let mut c = std::process::Command::new("cmd");
|
||||||
|
c.args(["/C", "start", ""]).arg(path);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = std::process::Command::new("xdg-open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
command
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||||
|
}
|
||||||
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use app_tauri_lib::dto::{
|
||||||
|
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
|
||||||
|
PluginRuntimePluginDto,
|
||||||
|
};
|
||||||
|
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||||
|
let dto = PluginAdminDto {
|
||||||
|
id: "dev.acme.gitgraph".to_owned(),
|
||||||
|
display_name: "Git Graph".to_owned(),
|
||||||
|
publisher: Some("Acme".to_owned()),
|
||||||
|
version: "1.2.3".to_owned(),
|
||||||
|
description: Some("Graph".to_owned()),
|
||||||
|
icon_url: Some("idea-plugin://dev.acme.gitgraph/1.2.3/abc/assets/icon.svg".to_owned()),
|
||||||
|
source_kind: "archive".to_owned(),
|
||||||
|
source_label: Some("/tmp/gitgraph.ideaplug".to_owned()),
|
||||||
|
lifecycle_state: PluginLifecycleState::PendingDisable,
|
||||||
|
enabled: false,
|
||||||
|
pending_enable_state: Some(false),
|
||||||
|
pending_uninstall: false,
|
||||||
|
restart_required: true,
|
||||||
|
trust_level: PluginTrustLevel::Full,
|
||||||
|
contribution_summary: PluginContributionSummaryDto {
|
||||||
|
top_level_menus: 1,
|
||||||
|
menu_items: 2,
|
||||||
|
layouts: 3,
|
||||||
|
mcp_servers: 4,
|
||||||
|
},
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
assert_eq!(value["displayName"], "Git Graph");
|
||||||
|
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||||
|
assert_eq!(value["trustLevel"], "full");
|
||||||
|
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||||
|
let dto = PluginRuntimeContributionCatalogDto {
|
||||||
|
plugins: vec![PluginRuntimePluginDto {
|
||||||
|
id: "dev.acme.gitgraph".to_owned(),
|
||||||
|
display_name: "Git Graph".to_owned(),
|
||||||
|
publisher: None,
|
||||||
|
version: "1.2.3".to_owned(),
|
||||||
|
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
|
||||||
|
icon_url: None,
|
||||||
|
content_hash: "abc".to_owned(),
|
||||||
|
contributes: PluginContributionSet::default(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value["plugins"][0]["bundleUrl"],
|
||||||
|
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
|
||||||
|
);
|
||||||
|
assert_eq!(value["plugins"][0]["contentHash"], "abc");
|
||||||
|
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ pub mod memory;
|
|||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
@ -139,6 +140,13 @@ pub use permission::{
|
|||||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||||
UpdateProjectPermissionsInput,
|
UpdateProjectPermissionsInput,
|
||||||
};
|
};
|
||||||
|
pub use plugin::{
|
||||||
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator,
|
||||||
|
ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary,
|
||||||
|
PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin,
|
||||||
|
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||||
|
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||||
|
};
|
||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||||
|
|||||||
1714
crates/application/src/plugin/mod.rs
Normal file
1714
crates/application/src/plugin/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -629,6 +629,7 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
|
|||||||
Some((l.conversation_id.clone(), l.agent_was_running))
|
Some((l.conversation_id.clone(), l.agent_was_running))
|
||||||
}
|
}
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
@ -645,6 +646,7 @@ fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,6 +218,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,253 @@ use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, Turn
|
|||||||
|
|
||||||
pub use crate::ticket_dto::*;
|
pub use crate::ticket_dto::*;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugins (#43)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Plugin contribution summary DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginContributionSummaryDto {
|
||||||
|
/// Top-level menus count.
|
||||||
|
pub top_level_menus: usize,
|
||||||
|
/// Menu items count.
|
||||||
|
pub menu_items: usize,
|
||||||
|
/// Layouts count.
|
||||||
|
pub layouts: usize,
|
||||||
|
/// MCP servers count.
|
||||||
|
pub mcp_servers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
||||||
|
fn from(value: application::PluginContributionSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
top_level_menus: value.top_level_menus,
|
||||||
|
menu_items: value.menu_items,
|
||||||
|
layouts: value.layouts,
|
||||||
|
mcp_servers: value.mcp_servers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin plugin DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginAdminDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Icon URL.
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
/// Source kind.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Source label.
|
||||||
|
pub source_label: Option<String>,
|
||||||
|
/// Lifecycle state.
|
||||||
|
pub lifecycle_state: domain::PluginLifecycleState,
|
||||||
|
/// Enabled flag.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Pending enable state.
|
||||||
|
pub pending_enable_state: Option<bool>,
|
||||||
|
/// Pending uninstall flag.
|
||||||
|
pub pending_uninstall: bool,
|
||||||
|
/// Restart required flag.
|
||||||
|
pub restart_required: bool,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: domain::PluginTrustLevel,
|
||||||
|
/// Contribution summary.
|
||||||
|
pub contribution_summary: PluginContributionSummaryDto,
|
||||||
|
/// Optional error.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginAdmin> for PluginAdminDto {
|
||||||
|
fn from(value: application::PluginAdmin) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
display_name: value.display_name,
|
||||||
|
publisher: value.publisher,
|
||||||
|
version: value.version,
|
||||||
|
description: value.description,
|
||||||
|
icon_url: value.icon_url,
|
||||||
|
source_kind: value.source_kind,
|
||||||
|
source_label: value.source_label,
|
||||||
|
lifecycle_state: value.lifecycle_state,
|
||||||
|
enabled: value.enabled,
|
||||||
|
pending_enable_state: value.pending_enable_state,
|
||||||
|
pending_uninstall: value.pending_uninstall,
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
trust_level: value.trust_level,
|
||||||
|
contribution_summary: value.contribution_summary.into(),
|
||||||
|
error: value.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Review request DTO.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReviewPluginPackageDto {
|
||||||
|
/// Source kind: `archive` or `directory`.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Local source path.
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin review DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginReviewDto {
|
||||||
|
/// Manifest id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Source kind.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Source label.
|
||||||
|
pub source_label: Option<String>,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: String,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: domain::PluginTrustLevel,
|
||||||
|
/// Summary.
|
||||||
|
pub contribution_summary: PluginContributionSummaryDto,
|
||||||
|
/// Manifest contributions.
|
||||||
|
pub contributes: domain::PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginReview> for PluginReviewDto {
|
||||||
|
fn from(value: application::PluginReview) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.manifest.id.as_str().to_owned(),
|
||||||
|
display_name: value.manifest.display_name,
|
||||||
|
publisher: value.manifest.publisher,
|
||||||
|
version: value.manifest.version.as_str().to_owned(),
|
||||||
|
description: value.manifest.description,
|
||||||
|
source_kind: value.source.kind().to_owned(),
|
||||||
|
source_label: Some(value.source.label().to_owned()),
|
||||||
|
content_hash: value.content_hash,
|
||||||
|
trust_level: value.trust_level,
|
||||||
|
contribution_summary: value.contribution_summary.into(),
|
||||||
|
contributes: value.manifest.contributes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin install result DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginInstallResultDto {
|
||||||
|
/// Installed plugin.
|
||||||
|
pub plugin: PluginAdminDto,
|
||||||
|
/// Review.
|
||||||
|
pub review: PluginReviewDto,
|
||||||
|
/// Restart required.
|
||||||
|
pub restart_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginInstallResult> for PluginInstallResultDto {
|
||||||
|
fn from(value: application::PluginInstallResult) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin: value.plugin.into(),
|
||||||
|
review: value.review.into(),
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin uninstall result DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginUninstallResultDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub plugin_id: String,
|
||||||
|
/// Removal outcome.
|
||||||
|
pub removal_outcome: domain::RemovalOutcome,
|
||||||
|
/// Restart required.
|
||||||
|
pub restart_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::UninstallPluginResult> for PluginUninstallResultDto {
|
||||||
|
fn from(value: application::UninstallPluginResult) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin_id: value.plugin_id,
|
||||||
|
removal_outcome: value.removal_outcome,
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime catalog DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRuntimeContributionCatalogDto {
|
||||||
|
/// Runtime plugins.
|
||||||
|
pub plugins: Vec<PluginRuntimePluginDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime plugin DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRuntimePluginDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Bundle URL.
|
||||||
|
pub bundle_url: String,
|
||||||
|
/// Icon URL.
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: String,
|
||||||
|
/// Contributions.
|
||||||
|
pub contributes: domain::PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginRuntimeCatalog> for PluginRuntimeContributionCatalogDto {
|
||||||
|
fn from(value: application::PluginRuntimeCatalog) -> Self {
|
||||||
|
Self {
|
||||||
|
plugins: value
|
||||||
|
.plugins
|
||||||
|
.into_iter()
|
||||||
|
.map(PluginRuntimePluginDto::from)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||||
|
fn from(value: application::PluginRuntimePlugin) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
display_name: value.display_name,
|
||||||
|
publisher: value.publisher,
|
||||||
|
version: value.version,
|
||||||
|
bundle_url: value.bundle_url,
|
||||||
|
icon_url: value.icon_url,
|
||||||
|
content_hash: value.content_hash,
|
||||||
|
contributes: value.contributes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for the `health` command.
|
/// Request DTO for the `health` command.
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -119,6 +119,60 @@ pub enum DomainEventDto {
|
|||||||
/// Project id (UUID string).
|
/// Project id (UUID string).
|
||||||
project_id: String,
|
project_id: String,
|
||||||
},
|
},
|
||||||
|
/// A plugin was installed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginInstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Installed version.
|
||||||
|
version: String,
|
||||||
|
},
|
||||||
|
/// A plugin was enabled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginEnabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
},
|
||||||
|
/// A plugin was disabled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginDisabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin was uninstalled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginUninstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin failed to load.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginLoadFailed {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Failure reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server started.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginMcpServerStarted {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Server id.
|
||||||
|
server_id: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server stopped.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginMcpServerStopped {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Server id.
|
||||||
|
server_id: String,
|
||||||
|
},
|
||||||
/// An agent was launched.
|
/// An agent was launched.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
AgentLaunched {
|
AgentLaunched {
|
||||||
@ -677,6 +731,45 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
version: version.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginDisabled {
|
||||||
|
plugin_id,
|
||||||
|
restart_required,
|
||||||
|
} => Self::PluginDisabled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
restart_required: *restart_required,
|
||||||
|
},
|
||||||
|
DomainEvent::PluginUninstalled {
|
||||||
|
plugin_id,
|
||||||
|
restart_required,
|
||||||
|
} => Self::PluginUninstalled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
restart_required: *restart_required,
|
||||||
|
},
|
||||||
|
DomainEvent::PluginLoadFailed { plugin_id, reason } => Self::PluginLoadFailed {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginMcpServerStarted {
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
} => Self::PluginMcpServerStarted {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
server_id: server_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginMcpServerStopped {
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
} => Self::PluginMcpServerStopped {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
server_id: server_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
DomainEvent::AgentLaunched {
|
DomainEvent::AgentLaunched {
|
||||||
agent_id,
|
agent_id,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@ -24,27 +24,30 @@ use application::{
|
|||||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
|
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
|
||||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||||
|
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||||
|
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||||
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
||||||
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||||
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill,
|
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||||
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -53,9 +56,10 @@ use domain::ports::{
|
|||||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||||
PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler,
|
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||||
SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker,
|
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask,
|
||||||
WakeError, WakeReason, WindowStateStore,
|
Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore,
|
||||||
|
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -73,10 +77,11 @@ use infrastructure::{
|
|||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||||
|
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
@ -1062,6 +1067,29 @@ pub struct BackendCore {
|
|||||||
pub retry_background_task: Arc<RetryBackgroundTask>,
|
pub retry_background_task: Arc<RetryBackgroundTask>,
|
||||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||||
|
// --- Plugins (#43) ---
|
||||||
|
/// Review a local plugin package before install.
|
||||||
|
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
||||||
|
/// Install a plugin archive.
|
||||||
|
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
|
||||||
|
/// Install a plugin directory snapshot.
|
||||||
|
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
|
||||||
|
/// List installed plugins for admin UI.
|
||||||
|
pub list_plugins: Arc<ListPlugins>,
|
||||||
|
/// Enable or disable a plugin.
|
||||||
|
pub set_plugin_enabled: Arc<SetPluginEnabled>,
|
||||||
|
/// Uninstall a plugin.
|
||||||
|
pub uninstall_plugin: Arc<UninstallPlugin>,
|
||||||
|
/// List runtime contributions for UI bootstrap.
|
||||||
|
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
||||||
|
/// Reconcile external MCP plugin servers.
|
||||||
|
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
||||||
|
/// Package store exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
||||||
|
/// Registry store exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
||||||
|
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
|
||||||
// --- Templates & sync (L7) ---
|
// --- Templates & sync (L7) ---
|
||||||
/// Create a template in the global store.
|
/// Create a template in the global store.
|
||||||
pub create_template: Arc<CreateTemplate>,
|
pub create_template: Arc<CreateTemplate>,
|
||||||
@ -1233,6 +1261,17 @@ impl BackendCore {
|
|||||||
let pair_attempt_limiter_port =
|
let pair_attempt_limiter_port =
|
||||||
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
||||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
|
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||||
|
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
||||||
|
let plugin_validator =
|
||||||
|
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||||
|
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||||
|
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
||||||
|
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
||||||
|
let plugin_manifest_validator =
|
||||||
|
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||||
|
let plugin_mcp_supervisor_port =
|
||||||
|
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
||||||
|
|
||||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||||
let health = Arc::new(HealthUseCase::new(
|
let health = Arc::new(HealthUseCase::new(
|
||||||
@ -2089,6 +2128,53 @@ impl BackendCore {
|
|||||||
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
||||||
Arc::clone(&spawn_background_command),
|
Arc::clone(&spawn_background_command),
|
||||||
));
|
));
|
||||||
|
let review_plugin_package = Arc::new(ReviewPluginPackage::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let list_plugins = Arc::new(ListPlugins::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::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 install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::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 set_plugin_enabled = Arc::new(SetPluginEnabled::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 uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
|
let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
let background_wake = Arc::new(AgentWakeService::new(
|
let background_wake = Arc::new(AgentWakeService::new(
|
||||||
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
||||||
Arc::clone(&input_mediator),
|
Arc::clone(&input_mediator),
|
||||||
@ -2613,6 +2699,17 @@ impl BackendCore {
|
|||||||
cancel_background_task,
|
cancel_background_task,
|
||||||
retry_background_task,
|
retry_background_task,
|
||||||
background_task_store: Arc::clone(&background_tasks_port),
|
background_task_store: Arc::clone(&background_tasks_port),
|
||||||
|
review_plugin_package,
|
||||||
|
install_plugin_from_archive,
|
||||||
|
install_plugin_from_directory,
|
||||||
|
list_plugins,
|
||||||
|
set_plugin_enabled,
|
||||||
|
uninstall_plugin,
|
||||||
|
list_plugin_runtime_contributions,
|
||||||
|
reconcile_plugin_mcp_servers,
|
||||||
|
plugin_package_store: Arc::clone(&plugin_packages),
|
||||||
|
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
||||||
|
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
||||||
ticket_tool_binder,
|
ticket_tool_binder,
|
||||||
template_tool_binder,
|
template_tool_binder,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use crate::ids::{
|
|||||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::memory::MemorySlug;
|
use crate::memory::MemorySlug;
|
||||||
|
use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion};
|
||||||
use crate::sprint::{SprintOrder, SprintVersion};
|
use crate::sprint::{SprintOrder, SprintVersion};
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
@ -41,6 +42,53 @@ pub enum DomainEvent {
|
|||||||
/// The new project.
|
/// The new project.
|
||||||
project_id: ProjectId,
|
project_id: ProjectId,
|
||||||
},
|
},
|
||||||
|
/// A plugin was installed.
|
||||||
|
PluginInstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Installed version.
|
||||||
|
version: PluginVersion,
|
||||||
|
},
|
||||||
|
/// A plugin was enabled.
|
||||||
|
PluginEnabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
},
|
||||||
|
/// A plugin was disabled.
|
||||||
|
PluginDisabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin was uninstalled.
|
||||||
|
PluginUninstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin failed to load.
|
||||||
|
PluginLoadFailed {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Failure reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server started.
|
||||||
|
PluginMcpServerStarted {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Server id.
|
||||||
|
server_id: PluginMcpServerId,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server stopped.
|
||||||
|
PluginMcpServerStopped {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Server id.
|
||||||
|
server_id: PluginMcpServerId,
|
||||||
|
},
|
||||||
/// An agent was launched in a terminal.
|
/// An agent was launched in a terminal.
|
||||||
AgentLaunched {
|
AgentLaunched {
|
||||||
/// The agent.
|
/// The agent.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
||||||
|
use crate::plugin::{PluginId, PluginLayoutType};
|
||||||
|
|
||||||
/// Direction of a [`SplitContainer`].
|
/// Direction of a [`SplitContainer`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -160,6 +161,24 @@ pub struct GridContainer {
|
|||||||
pub cells: Vec<GridCell>,
|
pub cells: Vec<GridCell>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted custom layout cell provided by an installed plugin.
|
||||||
|
///
|
||||||
|
/// The domain stores only the stable provider identity, layout type, and opaque
|
||||||
|
/// state. It never stores or resolves the React component used to render it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomPluginLayoutCell {
|
||||||
|
/// Node identifier.
|
||||||
|
pub id: NodeId,
|
||||||
|
/// Provider plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Persisted layout type declared by the provider plugin.
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Opaque plugin-owned state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
/// A node in the layout tree.
|
/// A node in the layout tree.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
||||||
@ -170,6 +189,8 @@ pub enum LayoutNode {
|
|||||||
Split(SplitContainer),
|
Split(SplitContainer),
|
||||||
/// A spreadsheet-style grid.
|
/// A spreadsheet-style grid.
|
||||||
Grid(GridContainer),
|
Grid(GridContainer),
|
||||||
|
/// A plugin-provided custom layout leaf.
|
||||||
|
CustomPluginLayout(CustomPluginLayoutCell),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The root of a layout (one per tab).
|
/// The root of a layout (one per tab).
|
||||||
@ -663,6 +684,7 @@ impl LayoutTree {
|
|||||||
out.push((leaf.id, agent));
|
out.push((leaf.id, agent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LayoutNode::CustomPluginLayout(_) => {}
|
||||||
LayoutNode::Split(split) => {
|
LayoutNode::Split(split) => {
|
||||||
for child in &split.children {
|
for child in &split.children {
|
||||||
walk(&child.node, out);
|
walk(&child.node, out);
|
||||||
@ -770,6 +792,7 @@ impl LayoutTree {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
|
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -784,6 +807,7 @@ impl LayoutTree {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -797,6 +821,7 @@ impl LayoutTree {
|
|||||||
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
||||||
let rebuilt = match node {
|
let rebuilt = match node {
|
||||||
LayoutNode::Leaf(_) => node.clone(),
|
LayoutNode::Leaf(_) => node.clone(),
|
||||||
|
LayoutNode::CustomPluginLayout(_) => node.clone(),
|
||||||
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
||||||
id: split.id,
|
id: split.id,
|
||||||
direction: split.direction,
|
direction: split.direction,
|
||||||
@ -844,6 +869,7 @@ fn validate_node(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
LayoutNode::CustomPluginLayout(_) => Ok(()),
|
||||||
LayoutNode::Split(split) => {
|
LayoutNode::Split(split) => {
|
||||||
if split.children.is_empty() {
|
if split.children.is_empty() {
|
||||||
return Err(LayoutError::EmptySplit);
|
return Err(LayoutError::EmptySplit);
|
||||||
|
|||||||
@ -54,6 +54,7 @@ pub mod memory_harvest;
|
|||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@ -178,10 +179,10 @@ pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
|||||||
pub use git::GitRepository;
|
pub use git::GitRepository;
|
||||||
|
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode,
|
||||||
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
LayoutTree, LeafCell, PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition,
|
||||||
PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot,
|
PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, WeightedChild, Window,
|
||||||
Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use events::{DomainEvent, OrchestrationSource};
|
pub use events::{DomainEvent, OrchestrationSource};
|
||||||
@ -193,6 +194,16 @@ pub use permission::{
|
|||||||
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use plugin::{
|
||||||
|
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId,
|
||||||
|
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource,
|
||||||
|
PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest,
|
||||||
|
PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus,
|
||||||
|
PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry,
|
||||||
|
PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
||||||
|
RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||||
|
};
|
||||||
|
|
||||||
pub use sandbox::{
|
pub use sandbox::{
|
||||||
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||||
SandboxKind, SandboxPlan, SandboxStatus,
|
SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
@ -210,11 +221,13 @@ pub use ports::{
|
|||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||||
IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery,
|
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||||
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||||
|
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||||
|
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
681
crates/domain/src/plugin.rs
Normal file
681
crates/domain/src/plugin.rs
Normal file
@ -0,0 +1,681 @@
|
|||||||
|
//! Plugin domain model and validated manifest image.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Plugin domain validation error.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginError {
|
||||||
|
/// A required field is empty or malformed.
|
||||||
|
#[error("invalid plugin field {field}: {reason}")]
|
||||||
|
InvalidField {
|
||||||
|
/// Field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Human-readable reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A path escaped the plugin root.
|
||||||
|
#[error("invalid plugin path {field}: {path}")]
|
||||||
|
InvalidPath {
|
||||||
|
/// Field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Offending path.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// A contribution id is duplicated within the same plugin.
|
||||||
|
#[error("duplicate contribution id: {0}")]
|
||||||
|
DuplicateContribution(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable plugin identifier.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginId(String);
|
||||||
|
|
||||||
|
impl PluginId {
|
||||||
|
/// Validates and creates a plugin id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
let valid_len = (3..=128).contains(&raw.len());
|
||||||
|
let mut chars = raw.chars();
|
||||||
|
let first = chars.next().unwrap_or('\0');
|
||||||
|
let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit();
|
||||||
|
let valid_rest =
|
||||||
|
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
|
||||||
|
if valid_len && valid_first && valid_rest {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "id",
|
||||||
|
reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PluginId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SemVer-like plugin version.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginVersion(String);
|
||||||
|
|
||||||
|
impl PluginVersion {
|
||||||
|
/// Validates and creates a version.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a);
|
||||||
|
let parts: Vec<&str> = core.split('.').collect();
|
||||||
|
let ok = parts.len() == 3
|
||||||
|
&& parts.iter().all(|p| {
|
||||||
|
!p.is_empty()
|
||||||
|
&& p.chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& (p == &"0" || !p.starts_with('0'))
|
||||||
|
});
|
||||||
|
if ok {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "version",
|
||||||
|
reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw version.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative path inside a plugin package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct RelativePath(String);
|
||||||
|
|
||||||
|
impl RelativePath {
|
||||||
|
/// Validates a manifest path as relative, normalized, and root-confined.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if is_safe_relative_path(&raw) {
|
||||||
|
Ok(Self(raw.replace('\\', "/")))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidPath {
|
||||||
|
field: "path",
|
||||||
|
path: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_relative_path(raw: &str) -> bool {
|
||||||
|
if raw.is_empty()
|
||||||
|
|| raw.starts_with('/')
|
||||||
|
|| raw.starts_with('\\')
|
||||||
|
|| raw.contains('\0')
|
||||||
|
|| raw.contains(':')
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
raw.replace('\\', "/")
|
||||||
|
.split('/')
|
||||||
|
.all(|p| !p.is_empty() && p != "." && p != "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content hash of a plugin bundle/package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ContentHash(String);
|
||||||
|
|
||||||
|
impl ContentHash {
|
||||||
|
/// Creates a content hash value.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "contentHash",
|
||||||
|
reason: "expected non-empty hexadecimal hash".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the hash.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL exposed to the frontend for a plugin bundle or asset.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginBundleUrl(String);
|
||||||
|
|
||||||
|
impl PluginBundleUrl {
|
||||||
|
/// Creates a bundle URL.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(raw: impl Into<String>) -> Self {
|
||||||
|
Self(raw.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the URL.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package reference managed by the store adapter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginPackageRef {
|
||||||
|
/// Plugin id if the package is already committed.
|
||||||
|
pub plugin_id: Option<PluginId>,
|
||||||
|
/// Opaque adapter-owned root path label.
|
||||||
|
pub root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staged package produced by install/review.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StagedPluginPackage {
|
||||||
|
/// Opaque staging root path.
|
||||||
|
pub root: String,
|
||||||
|
/// Original source label.
|
||||||
|
pub source: PluginInstallSource,
|
||||||
|
/// Hash of package contents at staging time.
|
||||||
|
pub content_hash: ContentHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin install source persisted for admin display.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
pub enum PluginInstallSource {
|
||||||
|
/// Local archive source.
|
||||||
|
Archive {
|
||||||
|
/// Human-readable path label.
|
||||||
|
path_label: String,
|
||||||
|
},
|
||||||
|
/// Local directory source.
|
||||||
|
Directory {
|
||||||
|
/// Human-readable path label.
|
||||||
|
path_label: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginInstallSource {
|
||||||
|
/// Returns the stable DTO source kind.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Archive { .. } => "archive",
|
||||||
|
Self::Directory { .. } => "directory",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional label.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Archive { path_label } | Self::Directory { path_label } => path_label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin lifecycle state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum PluginLifecycleState {
|
||||||
|
/// Installed and enabled.
|
||||||
|
Enabled,
|
||||||
|
/// Installed and disabled.
|
||||||
|
Disabled,
|
||||||
|
/// Enable requested.
|
||||||
|
PendingEnable,
|
||||||
|
/// Disable requested.
|
||||||
|
PendingDisable,
|
||||||
|
/// Uninstall requested.
|
||||||
|
PendingUninstall,
|
||||||
|
/// Invalid manifest/incompatible engine.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginLifecycleState {
|
||||||
|
/// Whether the plugin can expose runtime contributions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_runtime_active(self) -> bool {
|
||||||
|
matches!(self, Self::Enabled | Self::PendingEnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trust level supported by v1.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PluginTrustLevel {
|
||||||
|
/// Full-trust plugin.
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declared plugin capability.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PluginCapability {
|
||||||
|
/// UI bundle/contributions.
|
||||||
|
Ui,
|
||||||
|
/// External MCP server declarations.
|
||||||
|
Mcp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin command id.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginCommandId(String);
|
||||||
|
|
||||||
|
impl PluginCommandId {
|
||||||
|
/// Validates and creates a command id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if raw.len() >= 3
|
||||||
|
&& raw
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||||
|
{
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "command",
|
||||||
|
reason: "invalid command id".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin layout type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginLayoutType(String);
|
||||||
|
|
||||||
|
impl PluginLayoutType {
|
||||||
|
/// Validates and creates a layout type.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw type.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin MCP server id.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginMcpServerId(String);
|
||||||
|
|
||||||
|
impl PluginMcpServerId {
|
||||||
|
/// Validates and creates a server id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level menu contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginTopLevelMenuContribution {
|
||||||
|
/// Menu id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Must be true for this contribution kind.
|
||||||
|
pub top_level: bool,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menu item contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMenuItemContribution {
|
||||||
|
/// Item id.
|
||||||
|
pub id: String,
|
||||||
|
/// Target menu id.
|
||||||
|
pub target_menu_id: String,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Command id.
|
||||||
|
pub command: PluginCommandId,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Optional declarative condition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginLayoutContribution {
|
||||||
|
/// Persisted layout type.
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Component export name.
|
||||||
|
pub component: String,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Optional declarative condition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP server contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpServerContribution {
|
||||||
|
/// Server id.
|
||||||
|
pub id: PluginMcpServerId,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Executable command path or absolute command when allowed.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Environment variables.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// Working directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Transport, v1 only `stdio`.
|
||||||
|
pub transport: String,
|
||||||
|
/// Auto start flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// Development-only escape hatch for absolute commands.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_absolute_command: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated contribution set.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginContributionSet {
|
||||||
|
/// Top-level menus.
|
||||||
|
#[serde(default)]
|
||||||
|
pub menus: Vec<PluginTopLevelMenuContribution>,
|
||||||
|
/// Menu items.
|
||||||
|
#[serde(default)]
|
||||||
|
pub menu_items: Vec<PluginMenuItemContribution>,
|
||||||
|
/// Layout contributions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub layouts: Vec<PluginLayoutContribution>,
|
||||||
|
/// MCP server contributions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers: Vec<PluginMcpServerContribution>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated plugin manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginManifest {
|
||||||
|
/// Manifest schema version.
|
||||||
|
pub idea_plugin_manifest_version: u32,
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: PluginId,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
#[serde(default)]
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: PluginVersion,
|
||||||
|
/// Description.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Engine constraint for IdeA.
|
||||||
|
#[serde(default)]
|
||||||
|
pub engine_idea: Option<String>,
|
||||||
|
/// Main ESM bundle.
|
||||||
|
pub main: RelativePath,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: PluginTrustLevel,
|
||||||
|
/// Capabilities.
|
||||||
|
#[serde(default)]
|
||||||
|
pub capabilities: Vec<PluginCapability>,
|
||||||
|
/// Contributions.
|
||||||
|
pub contributes: PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted registry entry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRegistryEntry {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: PluginId,
|
||||||
|
/// Lifecycle state.
|
||||||
|
pub lifecycle_state: PluginLifecycleState,
|
||||||
|
/// Source.
|
||||||
|
pub source: PluginInstallSource,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: ContentHash,
|
||||||
|
/// Restart required marker.
|
||||||
|
#[serde(default)]
|
||||||
|
pub restart_required: bool,
|
||||||
|
/// Optional error.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted global registry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRegistry {
|
||||||
|
/// Schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Registry entries.
|
||||||
|
pub plugins: Vec<PluginRegistryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
plugins: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistry {
|
||||||
|
/// Finds a plugin entry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> {
|
||||||
|
self.plugins.iter().find(|p| &p.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upserts a registry entry, preserving id uniqueness.
|
||||||
|
pub fn upsert(&mut self, entry: PluginRegistryEntry) {
|
||||||
|
if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) {
|
||||||
|
*slot = entry;
|
||||||
|
} else {
|
||||||
|
self.plugins.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an entry.
|
||||||
|
pub fn remove(&mut self, id: &PluginId) -> Option<PluginRegistryEntry> {
|
||||||
|
let index = self.plugins.iter().position(|p| &p.id == id)?;
|
||||||
|
Some(self.plugins.remove(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin descriptor assembled from manifest + registry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDescriptor {
|
||||||
|
/// Manifest.
|
||||||
|
pub manifest: PluginManifest,
|
||||||
|
/// Registry entry.
|
||||||
|
pub registry: PluginRegistryEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginDescriptor {
|
||||||
|
/// Returns true if runtime contributions are active.
|
||||||
|
#[must_use]
|
||||||
|
pub fn exposes_runtime_contributions(&self) -> bool {
|
||||||
|
self.registry.lifecycle_state.is_runtime_active()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of removing package files.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum RemovalOutcome {
|
||||||
|
/// Files were removed.
|
||||||
|
Removed,
|
||||||
|
/// Files were moved to trash for later cleanup.
|
||||||
|
Tombstoned,
|
||||||
|
/// No package files existed.
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved MCP server spec passed to the supervisor.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpServerSpec {
|
||||||
|
/// Plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Manifest server id.
|
||||||
|
pub server_id: PluginMcpServerId,
|
||||||
|
/// Stable external identity `plugin:<pluginId>:<serverId>`.
|
||||||
|
pub identity: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Command.
|
||||||
|
pub command: String,
|
||||||
|
/// Args.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Env.
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// Cwd.
|
||||||
|
pub cwd: String,
|
||||||
|
/// Transport.
|
||||||
|
pub transport: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP server status.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpStatus {
|
||||||
|
/// Identity.
|
||||||
|
pub identity: String,
|
||||||
|
/// Running flag.
|
||||||
|
pub running: bool,
|
||||||
|
/// Optional error.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP status set.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpStatusSet {
|
||||||
|
/// Statuses.
|
||||||
|
pub servers: Vec<PluginMcpStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom plugin layout persisted in a layout leaf's opaque content.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomPluginLayout {
|
||||||
|
/// Provider plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Provider display name if known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider_plugin_display_name: Option<String>,
|
||||||
|
/// Layout type.
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Opaque plugin state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_id_pattern_is_enforced() {
|
||||||
|
assert!(PluginId::new("dev.acme.gitgraph").is_ok());
|
||||||
|
assert!(PluginId::new("De.acme").is_err());
|
||||||
|
assert!(PluginId::new("a").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_paths_cannot_escape_root() {
|
||||||
|
assert_eq!(
|
||||||
|
RelativePath::new("dist/index.js").unwrap().as_str(),
|
||||||
|
"dist/index.js"
|
||||||
|
);
|
||||||
|
assert!(RelativePath::new("../dist/index.js").is_err());
|
||||||
|
assert!(RelativePath::new("/tmp/index.js").is_err());
|
||||||
|
assert!(RelativePath::new("dist/../index.js").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_and_pending_uninstall_are_not_runtime_active() {
|
||||||
|
assert!(!PluginLifecycleState::Disabled.is_runtime_active());
|
||||||
|
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
|
||||||
|
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,11 @@ use crate::model_server::{
|
|||||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||||
};
|
};
|
||||||
use crate::permission::ProjectPermissions;
|
use crate::permission::ProjectPermissions;
|
||||||
|
use crate::plugin::{
|
||||||
|
ContentHash, PluginBundleUrl, PluginId, PluginManifest, PluginMcpServerSpec,
|
||||||
|
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
||||||
|
StagedPluginPackage,
|
||||||
|
};
|
||||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
@ -279,6 +284,166 @@ impl RemotePath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque local path supplied by a driving adapter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct LocalPath(pub String);
|
||||||
|
|
||||||
|
impl LocalPath {
|
||||||
|
/// Wraps a raw path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(p: impl Into<String>) -> Self {
|
||||||
|
Self(p.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw manifest bytes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PluginManifestBytes {
|
||||||
|
/// Bytes of `idea-plugin.json`.
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin package store errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginStoreError {
|
||||||
|
/// Package was not found.
|
||||||
|
#[error("plugin package not found")]
|
||||||
|
NotFound,
|
||||||
|
/// Invalid input/source.
|
||||||
|
#[error("invalid plugin package: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
/// I/O error.
|
||||||
|
#[error("plugin package I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Serialization or archive error.
|
||||||
|
#[error("plugin package format error: {0}")]
|
||||||
|
Format(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin registry errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginRegistryError {
|
||||||
|
/// I/O error.
|
||||||
|
#[error("plugin registry I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Serialization error.
|
||||||
|
#[error("plugin registry serialization error: {0}")]
|
||||||
|
Serialization(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest validation errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginManifestError {
|
||||||
|
/// Invalid JSON.
|
||||||
|
#[error("invalid plugin manifest JSON: {0}")]
|
||||||
|
Json(String),
|
||||||
|
/// Invalid manifest data.
|
||||||
|
#[error("invalid plugin manifest: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
/// Incompatible engine.
|
||||||
|
#[error("incompatible IdeA engine: {0}")]
|
||||||
|
IncompatibleEngine(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin MCP errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginMcpError {
|
||||||
|
/// Process/supervisor failure.
|
||||||
|
#[error("plugin MCP error: {0}")]
|
||||||
|
Process(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for installed plugin packages under the global app data directory.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginPackageStore: Send + Sync {
|
||||||
|
/// Lists committed package roots.
|
||||||
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Reads `idea-plugin.json` from a package root.
|
||||||
|
async fn read_manifest(
|
||||||
|
&self,
|
||||||
|
package: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifestBytes, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Stages an archive for validation.
|
||||||
|
async fn install_from_archive(
|
||||||
|
&self,
|
||||||
|
archive: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Stages a directory snapshot for validation.
|
||||||
|
async fn install_from_directory(
|
||||||
|
&self,
|
||||||
|
dir: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Commits a staged install into `installed/<pluginId>`.
|
||||||
|
async fn commit_install(
|
||||||
|
&self,
|
||||||
|
staged: StagedPluginPackage,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<PluginPackageRef, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Removes a committed package.
|
||||||
|
async fn remove_package(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<RemovalOutcome, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Builds a protocol URL for a bundle/asset.
|
||||||
|
fn bundle_url(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
entry: &RelativePath,
|
||||||
|
hash: &ContentHash,
|
||||||
|
) -> Result<PluginBundleUrl, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Returns the global application data directory label when the adapter can
|
||||||
|
/// expose it for manifest variable substitution.
|
||||||
|
fn app_data_dir_label(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for the global plugin registry.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginRegistryStore: Send + Sync {
|
||||||
|
/// Loads the registry, returning an empty document if missing.
|
||||||
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError>;
|
||||||
|
|
||||||
|
/// Persists the registry.
|
||||||
|
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest parser and validator.
|
||||||
|
pub trait PluginManifestValidator: Send + Sync {
|
||||||
|
/// Validates raw manifest bytes.
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
bytes: &[u8],
|
||||||
|
package_root: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifest, PluginManifestError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supervisor for external MCP servers declared by plugins.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginMcpSupervisor: Send + Sync {
|
||||||
|
/// Reconciles the desired active server set.
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
active_servers: Vec<PluginMcpServerSpec>,
|
||||||
|
) -> Result<PluginMcpStatusSet, PluginMcpError>;
|
||||||
|
|
||||||
|
/// Stops every server owned by one plugin.
|
||||||
|
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// A single directory entry returned by [`FileSystem::list`].
|
/// A single directory entry returned by [`FileSystem::list`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DirEntry {
|
pub struct DirEntry {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option<LeafCell> {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
|
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -235,6 +236,7 @@ fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option<domain::SessionI
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection,
|
||||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
CustomPluginLayoutCell, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry,
|
||||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
|
MarkdownDoc, PluginId, PluginLayoutType, Project, ProjectPath, RemoteRef, SessionStrategy,
|
||||||
TemplateVersion, WeightedChild,
|
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||||
};
|
};
|
||||||
use helpers::{node, session};
|
use helpers::{node, session};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -472,3 +472,29 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
|
|||||||
"agent field should be omitted when None; json was {json2}"
|
"agent field should be omitted when None; json was {json2}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_layout_roundtrips_with_opaque_state() {
|
||||||
|
let tree = LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
|
||||||
|
id: node(43),
|
||||||
|
plugin_id: PluginId::new("dev.acme.gitgraph").unwrap(),
|
||||||
|
layout_type: PluginLayoutType::new("dev.acme.gitgraph.layout").unwrap(),
|
||||||
|
state: serde_json::json!({"branch":"main","zoom":2}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(roundtrip(&tree), tree);
|
||||||
|
let json = serde_json::to_string(&tree).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains("\"type\":\"customPluginLayout\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"pluginId\":\"dev.acme.gitgraph\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"layoutType\":\"dev.acme.gitgraph.layout\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(json.contains("\"state\""), "json was {json}");
|
||||||
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ futures-util = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||||
@ -49,6 +51,7 @@ fastembed = { version = "5", default-features = false, features = ["hf-hub-rustl
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
landlock = "0.4.5"
|
landlock = "0.4.5"
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||||
|
|||||||
@ -30,6 +30,7 @@ pub mod model_server;
|
|||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod pair_attempt_limiter;
|
pub mod pair_attempt_limiter;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod ratelimit;
|
pub mod ratelimit;
|
||||||
@ -80,6 +81,7 @@ pub use orchestrator::{
|
|||||||
};
|
};
|
||||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||||
|
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||||
pub use process::LocalProcessSpawner;
|
pub use process::LocalProcessSpawner;
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use ratelimit::RateLimitParser;
|
pub use ratelimit::RateLimitParser;
|
||||||
|
|||||||
755
crates/infrastructure/src/plugin/mod.rs
Normal file
755
crates/infrastructure/src/plugin/mod.rs
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
//! Filesystem plugin stores and external MCP supervisor.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{
|
||||||
|
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||||
|
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||||
|
PluginMcpStatus, PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath,
|
||||||
|
RemovalOutcome, StagedPluginPackage,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::process::Child;
|
||||||
|
|
||||||
|
const REGISTRY_FILE: &str = "registry.json";
|
||||||
|
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||||
|
|
||||||
|
/// Filesystem package store under app-data `plugins/`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FsPluginPackageStore {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsPluginPackageStore {
|
||||||
|
/// Builds the store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: app_data_dir.into().join("plugins"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global plugin store root.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plugins_root(&self) -> PathBuf {
|
||||||
|
self.root.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the managed directory for one installed plugin id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn installed_plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||||
|
self.installed_dir().join(plugin_id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn staging_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("_staging")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("_trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_root(&self, package: &PluginPackageRef) -> PathBuf {
|
||||||
|
match &package.plugin_id {
|
||||||
|
Some(id) => self.installed_dir().join(id.as_str()),
|
||||||
|
None => PathBuf::from(&package.root),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an asset path if the request is root-confined.
|
||||||
|
pub fn resolve_asset_path(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
path: &RelativePath,
|
||||||
|
) -> Result<PathBuf, PluginStoreError> {
|
||||||
|
let root = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
let candidate = root.join(path.as_str());
|
||||||
|
let canonical_root = root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let canonical = candidate
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if canonical.starts_with(canonical_root) {
|
||||||
|
Ok(canonical)
|
||||||
|
} else {
|
||||||
|
Err(PluginStoreError::Invalid(
|
||||||
|
"plugin asset escapes package root".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_root(&self) -> Result<PathBuf, PluginStoreError> {
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.as_nanos();
|
||||||
|
let path = self.staging_dir().join(format!("stage-{ts}"));
|
||||||
|
fs::create_dir_all(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginPackageStore for FsPluginPackageStore {
|
||||||
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||||
|
let dir = self.installed_dir();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in fs::read_dir(dir).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if !entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if let Ok(id) = PluginId::new(name) {
|
||||||
|
out.push(PluginPackageRef {
|
||||||
|
plugin_id: Some(id),
|
||||||
|
root: entry.path().to_string_lossy().into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_manifest(
|
||||||
|
&self,
|
||||||
|
package: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||||
|
let bytes = fs::read(self.package_root(package).join(MANIFEST_FILE)).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
PluginStoreError::NotFound
|
||||||
|
} else {
|
||||||
|
PluginStoreError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(PluginManifestBytes { bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_archive(
|
||||||
|
&self,
|
||||||
|
archive: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
let stage = self.stage_root()?;
|
||||||
|
let status = std::process::Command::new("unzip")
|
||||||
|
.arg("-q")
|
||||||
|
.arg(archive.as_str())
|
||||||
|
.arg("-d")
|
||||||
|
.arg(&stage)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(PluginStoreError::Format(format!(
|
||||||
|
"unzip exited with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
ensure_manifest(&stage)?;
|
||||||
|
let content_hash = hash_dir(&stage)?;
|
||||||
|
Ok(StagedPluginPackage {
|
||||||
|
root: stage.to_string_lossy().into_owned(),
|
||||||
|
source: PluginInstallSource::Archive {
|
||||||
|
path_label: archive.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
content_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_directory(
|
||||||
|
&self,
|
||||||
|
dir: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
let source = PathBuf::from(dir.as_str());
|
||||||
|
if !source.is_dir() {
|
||||||
|
return Err(PluginStoreError::NotFound);
|
||||||
|
}
|
||||||
|
let stage = self.stage_root()?;
|
||||||
|
copy_dir_all(&source, &stage)?;
|
||||||
|
ensure_manifest(&stage)?;
|
||||||
|
let content_hash = hash_dir(&stage)?;
|
||||||
|
Ok(StagedPluginPackage {
|
||||||
|
root: stage.to_string_lossy().into_owned(),
|
||||||
|
source: PluginInstallSource::Directory {
|
||||||
|
path_label: dir.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
content_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_install(
|
||||||
|
&self,
|
||||||
|
staged: StagedPluginPackage,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||||
|
fs::create_dir_all(self.installed_dir())
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let target = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
let tmp = self
|
||||||
|
.installed_dir()
|
||||||
|
.join(format!(".{}-new", plugin_id.as_str()));
|
||||||
|
if tmp.exists() {
|
||||||
|
fs::remove_dir_all(&tmp).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&staged.root, &tmp).or_else(|_| {
|
||||||
|
copy_dir_all(Path::new(&staged.root), &tmp)?;
|
||||||
|
fs::remove_dir_all(&staged.root).map_err(|e| PluginStoreError::Io(e.to_string()))
|
||||||
|
})?;
|
||||||
|
if target.exists() {
|
||||||
|
fs::remove_dir_all(&target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id.clone()),
|
||||||
|
root: target.to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_package(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||||
|
let target = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
if !target.exists() {
|
||||||
|
return Ok(RemovalOutcome::NotFound);
|
||||||
|
}
|
||||||
|
match fs::remove_dir_all(&target) {
|
||||||
|
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||||
|
Err(_) => {
|
||||||
|
fs::create_dir_all(self.trash_dir())
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.as_secs();
|
||||||
|
let tombstone = self
|
||||||
|
.trash_dir()
|
||||||
|
.join(format!("{}-{ts}", plugin_id.as_str()));
|
||||||
|
fs::rename(&target, tombstone).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(RemovalOutcome::Tombstoned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_url(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
entry: &RelativePath,
|
||||||
|
hash: &ContentHash,
|
||||||
|
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||||
|
Ok(PluginBundleUrl::new(format!(
|
||||||
|
"idea-plugin://{}/current/{}{}{}",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
hash.as_str(),
|
||||||
|
"/",
|
||||||
|
entry.as_str()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_dir_label(&self) -> Option<String> {
|
||||||
|
self.root
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||||
|
if root.join(MANIFEST_FILE).is_file() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PluginStoreError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||||
|
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ty = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let dest = target.join(entry.file_name());
|
||||||
|
if ty.is_dir() {
|
||||||
|
copy_dir_all(&entry.path(), &dest)?;
|
||||||
|
} else if ty.is_file() {
|
||||||
|
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_dir(root: &Path) -> Result<ContentHash, PluginStoreError> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files(root, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for path in files {
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
hasher.update(rel.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
let mut file = fs::File::open(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
hasher.update(buf);
|
||||||
|
hasher.update([0]);
|
||||||
|
}
|
||||||
|
ContentHash::new(hex::encode(hasher.finalize()))
|
||||||
|
.map_err(|e| PluginStoreError::Invalid(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStoreError> {
|
||||||
|
for entry in fs::read_dir(root).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ty = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if ty.is_dir() {
|
||||||
|
collect_files(&entry.path(), files)?;
|
||||||
|
} else if ty.is_file() {
|
||||||
|
files.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem registry store.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FsPluginRegistryStore {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsPluginRegistryStore {
|
||||||
|
/// Builds the store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: app_data_dir.into().join("plugins"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> PathBuf {
|
||||||
|
self.root.join(REGISTRY_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||||
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||||
|
match fs::read(self.path()) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|e| PluginRegistryError::Serialization(e.to_string())),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PluginRegistry::default()),
|
||||||
|
Err(e) => Err(PluginRegistryError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||||
|
fs::create_dir_all(&self.root).map_err(|e| PluginRegistryError::Io(e.to_string()))?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(registry)
|
||||||
|
.map_err(|e| PluginRegistryError::Serialization(e.to_string()))?;
|
||||||
|
fs::write(self.path(), bytes).map_err(|e| PluginRegistryError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
trait ExternalMcpServerHandle: Send {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
trait ExternalMcpServerBridge: Send + Sync {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProcessMcpServerHandle {
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerHandle for ProcessMcpServerHandle {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||||
|
self.child
|
||||||
|
.kill()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginMcpError::Process(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct StdioExternalMcpServerBridge;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerBridge for StdioExternalMcpServerBridge {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||||
|
if spec.transport != "stdio" {
|
||||||
|
return Err(PluginMcpError::Process(format!(
|
||||||
|
"unsupported plugin MCP transport: {}",
|
||||||
|
spec.transport
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut cmd = tokio::process::Command::new(&spec.command);
|
||||||
|
cmd.args(&spec.args)
|
||||||
|
.current_dir(&spec.cwd)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let env: BTreeMap<_, _> = spec.env.iter().cloned().collect();
|
||||||
|
cmd.envs(env);
|
||||||
|
let child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| PluginMcpError::Process(e.to_string()))?;
|
||||||
|
Ok(Box::new(ProcessMcpServerHandle { child }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External process supervisor for plugin MCP servers.
|
||||||
|
pub struct ExternalMcpPluginSupervisor {
|
||||||
|
bridge: Arc<dyn ExternalMcpServerBridge>,
|
||||||
|
children: Mutex<HashMap<String, Box<dyn ExternalMcpServerHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalMcpPluginSupervisor {
|
||||||
|
/// Builds the supervisor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bridge: Arc::new(StdioExternalMcpServerBridge),
|
||||||
|
children: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_bridge(bridge: Arc<dyn ExternalMcpServerBridge>) -> Self {
|
||||||
|
Self {
|
||||||
|
bridge,
|
||||||
|
children: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExternalMcpPluginSupervisor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
active_servers: Vec<PluginMcpServerSpec>,
|
||||||
|
) -> Result<PluginMcpStatusSet, PluginMcpError> {
|
||||||
|
let desired: HashSet<String> = active_servers.iter().map(|s| s.identity.clone()).collect();
|
||||||
|
let to_stop = {
|
||||||
|
let children = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned");
|
||||||
|
children
|
||||||
|
.keys()
|
||||||
|
.filter(|id| !desired.contains(*id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for id in to_stop {
|
||||||
|
let child = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.remove(&id);
|
||||||
|
if let Some(mut child) = child {
|
||||||
|
let _ = child.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut statuses = Vec::new();
|
||||||
|
for spec in active_servers {
|
||||||
|
let already = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.contains_key(&spec.identity);
|
||||||
|
if already {
|
||||||
|
statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: true,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.bridge.start(&spec).await {
|
||||||
|
Ok(handle) => {
|
||||||
|
self.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.insert(spec.identity.clone(), handle);
|
||||||
|
statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: true,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(PluginMcpStatusSet { servers: statuses })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
|
||||||
|
let prefix = format!("plugin:{}:", plugin_id.as_str());
|
||||||
|
let ids = {
|
||||||
|
let children = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned");
|
||||||
|
children
|
||||||
|
.keys()
|
||||||
|
.filter(|id| id.starts_with(&prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for id in ids {
|
||||||
|
let child = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.remove(&id);
|
||||||
|
if let Some(mut child) = child {
|
||||||
|
child.stop().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::PluginPackageStore;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"idea-plugin-test-{label}-{}-{n}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&path);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_plugin(root: &Path, main_body: &str) {
|
||||||
|
fs::create_dir_all(root.join("dist")).unwrap();
|
||||||
|
fs::write(root.join("dist/index.js"), main_body).unwrap();
|
||||||
|
fs::write(
|
||||||
|
root.join(MANIFEST_FILE),
|
||||||
|
r#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingBridge {
|
||||||
|
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||||
|
stopped: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordingHandle {
|
||||||
|
identity: String,
|
||||||
|
stopped: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerHandle for RecordingHandle {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||||
|
self.stopped.lock().unwrap().push(self.identity.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerBridge for RecordingBridge {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||||
|
assert_eq!(spec.transport, "stdio");
|
||||||
|
self.started.lock().unwrap().push(spec.clone());
|
||||||
|
Ok(Box::new(RecordingHandle {
|
||||||
|
identity: spec.identity.clone(),
|
||||||
|
stopped: Arc::clone(&self.stopped),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_spec(plugin_id: &str, server_id: &str) -> PluginMcpServerSpec {
|
||||||
|
let plugin_id = PluginId::new(plugin_id).unwrap();
|
||||||
|
let server_id = domain::PluginMcpServerId::new(server_id).unwrap();
|
||||||
|
PluginMcpServerSpec {
|
||||||
|
identity: format!("plugin:{}:{}", plugin_id.as_str(), server_id.as_str()),
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
display_name: "Plugin Tools".to_owned(),
|
||||||
|
command: "/plugin/servers/tool".to_owned(),
|
||||||
|
args: vec!["--stdio".to_owned()],
|
||||||
|
env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())],
|
||||||
|
cwd: "/plugin".to_owned(),
|
||||||
|
transport: "stdio".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn installs_directory_snapshot_and_uninstalls_cleanly() {
|
||||||
|
let app = temp_dir("app");
|
||||||
|
let source = temp_dir("source");
|
||||||
|
write_plugin(&source, "one");
|
||||||
|
let store = FsPluginPackageStore::new(&app);
|
||||||
|
let staged = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(staged.root, source.to_string_lossy());
|
||||||
|
let id = PluginId::new("dev.acme.test").unwrap();
|
||||||
|
let package = store.commit_install(staged, &id).await.unwrap();
|
||||||
|
assert!(PathBuf::from(package.root).join(MANIFEST_FILE).exists());
|
||||||
|
assert_eq!(
|
||||||
|
store.remove_package(&id).await.unwrap(),
|
||||||
|
RemovalOutcome::Removed
|
||||||
|
);
|
||||||
|
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn content_hash_changes_when_bundle_changes() {
|
||||||
|
let app = temp_dir("hash-app");
|
||||||
|
let source = temp_dir("hash-source");
|
||||||
|
write_plugin(&source, "one");
|
||||||
|
let store = FsPluginPackageStore::new(&app);
|
||||||
|
let first = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_hash;
|
||||||
|
fs::write(source.join("dist/index.js"), "two").unwrap();
|
||||||
|
let second = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_hash;
|
||||||
|
assert_ne!(first, second);
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extracts_archive_without_path_escape() {
|
||||||
|
if std::process::Command::new("zip")
|
||||||
|
.arg("-h")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_err()
|
||||||
|
|| std::process::Command::new("unzip")
|
||||||
|
.arg("-h")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app = temp_dir("archive-app");
|
||||||
|
let source = temp_dir("archive-source");
|
||||||
|
write_plugin(&source, "bundle");
|
||||||
|
let archive_path = app.join("plugin.ideaplug");
|
||||||
|
{
|
||||||
|
let status = std::process::Command::new("zip")
|
||||||
|
.arg("-qr")
|
||||||
|
.arg(&archive_path)
|
||||||
|
.arg(".")
|
||||||
|
.current_dir(&source)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
}
|
||||||
|
let store = FsPluginPackageStore::new(app.join("data"));
|
||||||
|
let staged = store
|
||||||
|
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||||
|
let bridge = Arc::new(RecordingBridge::default());
|
||||||
|
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||||
|
let spec = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||||
|
|
||||||
|
let statuses = supervisor.reconcile(vec![spec.clone()]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(statuses.servers.len(), 1);
|
||||||
|
assert_eq!(statuses.servers[0].identity, spec.identity);
|
||||||
|
assert!(statuses.servers[0].running);
|
||||||
|
assert_eq!(&*bridge.started.lock().unwrap(), &[spec]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_stop_plugin_stops_only_matching_plugin_servers() {
|
||||||
|
let bridge = Arc::new(RecordingBridge::default());
|
||||||
|
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||||
|
let target = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||||
|
let other = mcp_spec("dev.other.tools", "dev.other.tools.mcp");
|
||||||
|
supervisor
|
||||||
|
.reconcile(vec![target.clone(), other.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
supervisor.stop_plugin(&target.plugin_id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&*bridge.stopped.lock().unwrap(), &[target.identity.clone()]);
|
||||||
|
let statuses = supervisor.reconcile(vec![other.clone()]).await.unwrap();
|
||||||
|
assert_eq!(statuses.servers.len(), 1);
|
||||||
|
assert_eq!(statuses.servers[0].identity, other.identity);
|
||||||
|
assert_eq!(bridge.started.lock().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user