feat(application,backend,infrastructure,app-tauri,frontend): contributions UI/runtime des plugins limitées aux projets liés — #321 (QA verte)

Les contributions runtime des plugins (menus, slash commands, layouts,
surfaces) ne sont exposées que dans les projets liés au plugin.

- Définition du lien: ProjectPluginSettingsStore par racine projet
  (domaine) — une contribution runtime-active n'entre dans le catalogue
  que si elle est explicitement activée pour le projet courant; hors
  projet focalisé, catalogue vide
- Application: ListPluginRuntimeContributions.execute prend un
  project_id optionnel, charge projet + settings et filtre le registre
  par activation projet; tests fake store/settings (catalogue vide sans
  projet, activation, layout, slash commands)
- Backend/app-tauri: plugin_list_runtime_contributions et slash
  commands list/execute acceptent projectId optionnel (DTO camelCase,
  défaut serde), BackendCore recâblé avec store_port et
  project_plugin_settings_store; test DTO de scope optionnel
- Frontend: PluginGateway.listRuntimeContributions(projectId) (ports,
  tauri, http/unsupported, mock alignés); PluginRuntimeProvider
  re-scope le catalogue au changement de focus projet (vidage du
  registre précédent, disposition des bundles, requêtes obsolètes
  ignorées par projectId/révision); PluginLayoutCellView ignore les
  références de layout devenues hors périmètre via onChooseAnotherLayout
  et ne rend rien pendant le rechargement (aucune fuite inter-projets)
- Tests: plugin_install_load (infrastructure), dto slash commands,
  plugin.test, PluginRuntimeProvider.test, PluginLayoutCellView.test

QA verte sur feature/321-plugin-contributions-linked-projects. Aucun push.
This commit is contained in:
2026-09-11 17:46:03 +02:00
parent 6bdbe755fa
commit bf33e58508
16 changed files with 523 additions and 110 deletions

View File

@ -15,8 +15,8 @@ use domain::{
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome,
StagedPluginPackage, TaskId,
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath,
ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -2948,6 +2948,8 @@ pub struct ListPluginRuntimeContributions {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
projects: Arc<dyn ProjectStore>,
project_settings: Arc<dyn ProjectPluginSettingsStore>,
}
impl ListPluginRuntimeContributions {
@ -2957,21 +2959,45 @@ impl ListPluginRuntimeContributions {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
projects: Arc<dyn ProjectStore>,
project_settings: Arc<dyn ProjectPluginSettingsStore>,
) -> Self {
Self {
packages,
registry,
validator,
projects,
project_settings,
}
}
/// Executes the use case.
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
///
/// Without a project scope, no project-visible contribution is exposed.
pub async fn execute(
&self,
project_id: Option<ProjectId>,
) -> Result<PluginRuntimeCatalog, AppError> {
let Some(project_id) = project_id else {
return Ok(PluginRuntimeCatalog {
plugins: Vec::new(),
});
};
let project = self
.projects
.load_project(project_id)
.await
.map_err(AppError::from)?;
let settings = self
.project_settings
.load(&project.root)
.await
.map_err(AppError::from)?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
let mut plugins = Vec::new();
let mut invalid_plugins = Vec::new();
for entry in registry.plugins.clone() {
if !entry.lifecycle_state.is_runtime_active() {
if !entry.lifecycle_state.is_runtime_active() || !settings.is_enabled(&entry.id) {
continue;
}
match runtime_plugin_from_entry(
@ -3797,6 +3823,60 @@ mod tests {
PluginId::new("dev.acme.gitgraph").unwrap()
}
fn runtime_project_id() -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(0x321))
}
fn runtime_project() -> Project {
Project::new(
runtime_project_id(),
"Runtime project",
ProjectPath::new("/runtime-project").unwrap(),
RemoteRef::Local,
0,
)
.unwrap()
}
fn runtime_usecase(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> ListPluginRuntimeContributions {
runtime_usecase_with_settings(
packages,
registry,
validator,
domain::ProjectPluginSettings {
plugins: vec![domain::ProjectPluginActivation {
plugin_id: plugin_id(),
enabled: true,
}],
..domain::ProjectPluginSettings::default()
},
)
}
fn runtime_usecase_with_settings(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
settings: domain::ProjectPluginSettings,
) -> ListPluginRuntimeContributions {
let project = runtime_project();
let projects = Arc::new(FakeProjectStore {
projects: Mutex::new(HashMap::from([(project.id, project)])),
});
let project_settings = Arc::new(FakeRuntimeProjectSettings { settings });
ListPluginRuntimeContributions::new(
packages,
registry,
validator,
projects,
project_settings,
)
}
fn content_hash(raw: &str) -> ContentHash {
ContentHash::new(raw).unwrap()
}
@ -4296,25 +4376,61 @@ mod tests {
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(state)),
});
let usecase =
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
let catalog = usecase.execute().await.unwrap();
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
assert!(catalog.plugins.is_empty(), "{state:?} must not be active");
}
}
#[tokio::test]
async fn runtime_catalog_without_project_scope_exposes_no_contributions() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
let catalog = usecase.execute(None).await.unwrap();
assert!(catalog.plugins.is_empty());
}
#[tokio::test]
async fn runtime_catalog_requires_explicit_project_activation_not_agent_assignment() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let settings = domain::ProjectPluginSettings {
agents: vec![domain::AgentPluginAssignments {
agent_id: AgentId::from_uuid(Uuid::from_u128(7)),
skills: vec![domain::PluginSkillRef {
plugin_id: plugin_id(),
skill_id: domain::PluginSkillId::new("build-debug").unwrap(),
}],
tools: Vec::new(),
}],
..domain::ProjectPluginSettings::default()
};
let usecase =
runtime_usecase_with_settings(packages, registry, Arc::new(validator()), settings);
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
assert!(catalog.plugins.is_empty());
}
#[tokio::test]
async fn runtime_catalog_carries_manifest_capabilities() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let usecase =
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
let catalog = usecase.execute().await.unwrap();
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
assert_eq!(catalog.plugins.len(), 1);
assert_eq!(
@ -4338,10 +4454,9 @@ mod tests {
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let usecase =
ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator()));
let usecase = runtime_usecase(packages, registry.clone(), Arc::new(validator()));
let catalog = usecase.execute().await.unwrap();
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
assert!(
catalog.plugins.is_empty(),
@ -4673,11 +4788,10 @@ mod tests {
}
)));
let runtime =
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()))
.execute()
.await
.unwrap();
let runtime = runtime_usecase(packages, registry, Arc::new(validator()))
.execute(Some(runtime_project_id()))
.await
.unwrap();
assert!(runtime.plugins.is_empty());
}
@ -4838,14 +4952,10 @@ mod tests {
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
let result = install.execute("/source/plugin".to_owned()).await.unwrap();
let runtime = ListPluginRuntimeContributions::new(
packages.clone(),
registry.clone(),
Arc::new(validator()),
)
.execute()
.await
.unwrap();
let runtime = runtime_usecase(packages.clone(), registry.clone(), Arc::new(validator()))
.execute(Some(runtime_project_id()))
.await
.unwrap();
assert_eq!(result.plugin.lifecycle_state, PluginLifecycleState::Enabled);
assert_eq!(runtime.plugins.len(), 1);
@ -4957,6 +5067,28 @@ mod tests {
}
}
struct FakeRuntimeProjectSettings {
settings: domain::ProjectPluginSettings,
}
#[async_trait::async_trait]
impl ProjectPluginSettingsStore for FakeRuntimeProjectSettings {
async fn load(
&self,
_project_root: &ProjectPath,
) -> Result<domain::ProjectPluginSettings, StoreError> {
Ok(self.settings.clone())
}
async fn save(
&self,
_project_root: &ProjectPath,
_settings: &domain::ProjectPluginSettings,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeWorkspaceFs {
files: Mutex<HashMap<String, Vec<u8>>>,