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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user