feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -8,8 +8,8 @@ use domain::ports::{
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
SpawnSpec,
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
};
use domain::{
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
pub contributes: PluginContributionSet,
}
/// Input for plugin-owned storage reads/deletes.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
}
/// Input for plugin-owned storage writes.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
/// JSON value to persist.
pub value: serde_json::Value,
}
/// Input for reviewing a package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPluginPackageInput {
@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError {
}
}
fn map_storage(e: PluginStorageError) -> AppError {
match e {
PluginStorageError::Invalid(m) => AppError::Invalid(m),
PluginStorageError::Io(m) => AppError::FileSystem(m),
PluginStorageError::Serialization(m) => AppError::Store(m),
}
}
fn map_registry(e: PluginRegistryError) -> AppError {
match e {
PluginRegistryError::Io(m) => AppError::Store(m),
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
pub plugin_id: String,
}
/// Plugin-owned key/value storage facade.
pub struct PluginStorageAccess {
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
}
impl PluginStorageAccess {
/// Builds the facade.
#[must_use]
pub fn new(
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
) -> Self {
Self { storage, registry }
}
/// Reads one plugin-owned JSON value.
pub async fn get(
&self,
input: PluginStorageGetInput,
) -> Result<Option<serde_json::Value>, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.get(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
/// Writes one plugin-owned JSON value.
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.set(&plugin_id, &input.key, input.value)
.await
.map_err(map_storage)
}
/// Deletes one plugin-owned JSON value.
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.delete(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let entry = registry
.find(&plugin_id)
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
if !entry.lifecycle_state.is_runtime_active() {
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
}
Ok(plugin_id)
}
}
fn validate_storage_key(key: &str) -> Result<(), AppError> {
if key.trim().is_empty() {
return Err(AppError::Invalid(
"plugin storage key must not be empty".to_owned(),
));
}
if key.len() > 512 {
return Err(AppError::Invalid(
"plugin storage key must not exceed 512 bytes".to_owned(),
));
}
if key.contains('\0') {
return Err(AppError::Invalid(
"plugin storage key must not contain NUL bytes".to_owned(),
));
}
Ok(())
}
/// Uninstalls a plugin.
pub struct UninstallPlugin {
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
storage,
registry,
events,
mcp,
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
.remove_package(&plugin_id)
.await
.map_err(map_store)?;
self.storage
.purge_plugin(&plugin_id)
.await
.map_err(map_storage)?;
self.events.publish(DomainEvent::PluginUninstalled {
plugin_id: plugin_id.clone(),
restart_required: true,
@ -3214,7 +3332,8 @@ mod tests {
use domain::ports::{
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
PluginStoreError, StoreError,
};
use domain::remote::RemoteRef;
use domain::{BackgroundTaskState, ProjectPath};
@ -3411,6 +3530,69 @@ mod tests {
}
}
#[derive(Default)]
struct FakeStorage {
values: Mutex<HashMap<(String, String), serde_json::Value>>,
purged: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl PluginStorageStore for FakeStorage {
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<serde_json::Value>, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
.cloned())
}
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: serde_json::Value,
) -> Result<(), PluginStorageError> {
self.values
.lock()
.unwrap()
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
Ok(())
}
async fn delete(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<bool, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
.is_some())
}
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError> {
self.purged
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
self.values
.lock()
.unwrap()
.retain(|(id, _), _| id != plugin_id.as_str());
Ok(RemovalOutcome::Removed)
}
}
#[derive(Default)]
struct FakeEvents {
events: Mutex<Vec<DomainEvent>>,
@ -3802,6 +3984,7 @@ mod tests {
#[tokio::test]
async fn uninstall_removes_registry_package_and_stops_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
@ -3809,6 +3992,7 @@ mod tests {
let mcp = Arc::new(FakeMcp::default());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events.clone(),
mcp.clone(),
@ -3825,6 +4009,7 @@ mod tests {
assert!(result.restart_required);
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
event,
@ -3835,6 +4020,89 @@ mod tests {
)));
}
#[tokio::test]
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!({"count": 2}),
})
.await
.unwrap();
let value = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap();
assert_eq!(value, Some(serde_json::json!({"count": 2})));
assert!(access
.delete(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap());
assert_eq!(
access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let inactive = access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!(1),
})
.await
.unwrap_err();
assert_eq!(
inactive,
AppError::Invalid("plugin is not runtime-active".to_owned())
);
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let invalid = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: " ".to_owned(),
})
.await
.unwrap_err();
assert_eq!(
invalid,
AppError::Invalid("plugin storage key must not be empty".to_owned())
);
}
#[tokio::test]
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
@ -3844,6 +4112,7 @@ mod tests {
let registry = Arc::new(FakeRegistry::default());
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let storage = Arc::new(FakeStorage::default());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
@ -3851,8 +4120,13 @@ mod tests {
events.clone(),
mcp.clone(),
);
let uninstall =
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events,
mcp.clone(),
);
install.execute("/source/plugin".to_owned()).await.unwrap();
uninstall