feat(layout): supporte la création de layouts plugins (customPluginLayout)
Étend le flux de création de layout backend (DTO, usecases, store) et le frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre d'ouvrir un layout déclaré par un plugin installé (ex. Android Health), sans passer par le message bloquant "extension backend pas encore livrée". Ticket #141 — QA vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -10,8 +10,10 @@ use app_tauri_lib::dto::{
|
||||
};
|
||||
use application::{
|
||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||
PluginLayoutOrigin,
|
||||
};
|
||||
use domain::ids::{AgentId, LayoutId, NodeId};
|
||||
use domain::{PluginId, PluginLayoutType};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -55,6 +57,24 @@ fn layout_info_dto_git_graph_kind() {
|
||||
assert_eq!(v["kind"], "gitGraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_info_dto_plugin_kind() {
|
||||
let info = LayoutInfo {
|
||||
id: lid(3),
|
||||
name: "Android Health".to_owned(),
|
||||
kind: LayoutKind::Plugin {
|
||||
plugin_origin: PluginLayoutOrigin {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
},
|
||||
state: json!({}),
|
||||
},
|
||||
};
|
||||
let dto = LayoutInfoDto::from(info);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["kind"], "plugin");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListLayoutsDto
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -138,6 +158,43 @@ fn create_layout_request_with_git_graph_kind() {
|
||||
assert_eq!(kind, application::LayoutKind::GitGraph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_with_plugin_kind() {
|
||||
let project_id = Uuid::from_u128(13).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"name": "Android Health",
|
||||
"kind": "plugin",
|
||||
"pluginOrigin": {
|
||||
"pluginId": "dev.idea.android-plugin",
|
||||
"layoutType": "idea-android.health"
|
||||
},
|
||||
"state": { "deviceId": "pixel-8" }
|
||||
});
|
||||
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let kind = dto.parse_kind().unwrap();
|
||||
match kind {
|
||||
application::LayoutKind::Plugin {
|
||||
plugin_origin,
|
||||
state,
|
||||
} => {
|
||||
assert_eq!(plugin_origin.plugin_id.as_str(), "dev.idea.android-plugin");
|
||||
assert_eq!(plugin_origin.layout_type.as_str(), "idea-android.health");
|
||||
assert_eq!(state["deviceId"], "pixel-8");
|
||||
}
|
||||
_ => panic!("expected plugin layout kind"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_plugin_kind_requires_origin() {
|
||||
let project_id = Uuid::from_u128(14).to_string();
|
||||
let raw = json!({ "projectId": project_id, "name": "Android Health", "kind": "plugin" });
|
||||
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let err = dto.parse_kind().unwrap_err();
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_unknown_kind_is_invalid() {
|
||||
let project_id = Uuid::from_u128(12).to_string();
|
||||
@ -260,6 +317,30 @@ fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setPluginLayoutState operation deserialisation (#141)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_plugin_layout_state_op_deserialises_opaque_state() {
|
||||
let target = nid(3);
|
||||
let raw = json!({
|
||||
"type": "setPluginLayoutState",
|
||||
"target": target.to_string(),
|
||||
"state": { "deviceId": "pixel-8", "healthy": true }
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetPluginLayoutState { target: t, state } => {
|
||||
assert_eq!(t, target);
|
||||
assert_eq!(state["deviceId"], "pixel-8");
|
||||
assert_eq!(state["healthy"], true);
|
||||
}
|
||||
_ => panic!("expected SetPluginLayoutState"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCellConversation operation deserialisation (T4b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -4,12 +4,18 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore};
|
||||
use domain::ports::{
|
||||
EventBus, FileSystem, IdGenerator, PluginManifestValidator, PluginPackageStore,
|
||||
PluginRegistryStore, ProjectStore,
|
||||
};
|
||||
use domain::{DomainEvent, LayoutId, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::store::{default_tree, persist_doc, resolve_doc, LayoutKind, NamedLayout};
|
||||
use super::store::{
|
||||
default_tree, persist_doc, plugin_layout_tree, resolve_doc, LayoutKind, NamedLayout,
|
||||
};
|
||||
use crate::plugin::runtime_plugin_from_entry;
|
||||
|
||||
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -70,7 +76,7 @@ impl ListLayouts {
|
||||
.map(|l| LayoutInfo {
|
||||
id: l.id,
|
||||
name: l.name.clone(),
|
||||
kind: l.kind,
|
||||
kind: l.kind.clone(),
|
||||
})
|
||||
.collect(),
|
||||
active_id: doc.active_id,
|
||||
@ -106,6 +112,9 @@ pub struct CreateLayout {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl CreateLayout {
|
||||
@ -116,12 +125,18 @@ impl CreateLayout {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fs,
|
||||
ids,
|
||||
events,
|
||||
packages,
|
||||
registry,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,13 +152,23 @@ impl CreateLayout {
|
||||
}
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
if let LayoutKind::Plugin { plugin_origin, .. } = &input.kind {
|
||||
self.ensure_runtime_layout_is_active(plugin_origin).await?;
|
||||
}
|
||||
|
||||
let id = LayoutId::from_uuid(self.ids.new_uuid());
|
||||
let tree = match &input.kind {
|
||||
LayoutKind::Terminal | LayoutKind::GitGraph => default_tree(),
|
||||
LayoutKind::Plugin {
|
||||
plugin_origin,
|
||||
state,
|
||||
} => plugin_layout_tree(plugin_origin, state.clone()),
|
||||
};
|
||||
doc.layouts.push(NamedLayout {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: input.kind,
|
||||
tree: default_tree(),
|
||||
tree,
|
||||
});
|
||||
doc.active_id = id; // a freshly-created layout becomes active.
|
||||
|
||||
@ -153,6 +178,42 @@ impl CreateLayout {
|
||||
});
|
||||
Ok(CreateLayoutOutput { layout_id: id })
|
||||
}
|
||||
|
||||
async fn ensure_runtime_layout_is_active(
|
||||
&self,
|
||||
origin: &super::store::PluginLayoutOrigin,
|
||||
) -> Result<(), AppError> {
|
||||
let registry = self
|
||||
.registry
|
||||
.load_registry()
|
||||
.await
|
||||
.map_err(|e| AppError::Store(e.to_string()))?;
|
||||
for entry in registry.plugins {
|
||||
if entry.id != origin.plugin_id || !entry.lifecycle_state.is_runtime_active() {
|
||||
continue;
|
||||
}
|
||||
let runtime =
|
||||
runtime_plugin_from_entry(self.packages.as_ref(), self.validator.as_ref(), entry)
|
||||
.await?;
|
||||
if runtime
|
||||
.contributes
|
||||
.layouts
|
||||
.iter()
|
||||
.any(|layout| layout.layout_type == origin.layout_type)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::Invalid(format!(
|
||||
"plugin `{}` does not contribute layout `{}`",
|
||||
origin.plugin_id.as_str(),
|
||||
origin.layout_type.as_str()
|
||||
)));
|
||||
}
|
||||
Err(AppError::Invalid(format!(
|
||||
"plugin `{}` is not active at runtime",
|
||||
origin.plugin_id.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -35,6 +35,7 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::PluginLayoutOrigin;
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
|
||||
@ -15,7 +15,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{FileSystem, RemotePath};
|
||||
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
|
||||
use domain::{
|
||||
CustomPluginLayoutCell, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, PluginId,
|
||||
PluginLayoutType, Project,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
|
||||
@ -33,7 +36,7 @@ const LAYOUTS_VERSION: u32 = 1;
|
||||
const DEFAULT_LAYOUT_NAME: &str = "Default";
|
||||
|
||||
/// Discriminates the kind of content a named layout holds.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LayoutKind {
|
||||
/// A terminal-grid layout (the original kind).
|
||||
@ -41,6 +44,23 @@ pub enum LayoutKind {
|
||||
Terminal,
|
||||
/// A Git-graph visualisation layout.
|
||||
GitGraph,
|
||||
/// A plugin-provided custom layout.
|
||||
Plugin {
|
||||
/// Provider plugin id and layout type.
|
||||
plugin_origin: PluginLayoutOrigin,
|
||||
/// Opaque initial state owned by the plugin.
|
||||
state: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stable origin of a plugin-provided custom layout.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLayoutOrigin {
|
||||
/// Provider plugin id.
|
||||
pub plugin_id: PluginId,
|
||||
/// Layout type declared by the provider plugin.
|
||||
pub layout_type: PluginLayoutType,
|
||||
}
|
||||
|
||||
/// One named layout: a stable id, a display name and its terminal grid tree.
|
||||
@ -118,6 +138,17 @@ pub fn default_tree() -> LayoutTree {
|
||||
LayoutTree::single(LeafCell::new(NodeId::new_random()))
|
||||
}
|
||||
|
||||
/// Builds a single custom plugin layout tree.
|
||||
#[must_use]
|
||||
pub fn plugin_layout_tree(origin: &PluginLayoutOrigin, state: serde_json::Value) -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
|
||||
id: NodeId::new_random(),
|
||||
plugin_id: origin.plugin_id.clone(),
|
||||
layout_type: origin.layout_type.clone(),
|
||||
state,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
|
||||
LayoutsDoc {
|
||||
|
||||
@ -98,6 +98,13 @@ pub enum LayoutOperation {
|
||||
/// Conversation id to record, or `None` to clear.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Persist opaque state for a plugin-provided custom layout leaf.
|
||||
SetPluginLayoutState {
|
||||
/// The custom plugin layout node.
|
||||
target: NodeId,
|
||||
/// Opaque plugin-owned state.
|
||||
state: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperation {
|
||||
@ -123,6 +130,9 @@ impl LayoutOperation {
|
||||
target,
|
||||
conversation_id,
|
||||
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
||||
Self::SetPluginLayoutState { target, state } => {
|
||||
tree.set_plugin_layout_state(*target, state.clone())
|
||||
}
|
||||
};
|
||||
result.map_err(map_layout_err)
|
||||
}
|
||||
|
||||
@ -114,10 +114,10 @@ pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
||||
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
||||
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
|
||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, PluginLayoutOrigin,
|
||||
ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use mcp_tool_permissions::{
|
||||
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
|
||||
|
||||
@ -2764,7 +2764,7 @@ impl ListPluginRuntimeContributions {
|
||||
}
|
||||
}
|
||||
|
||||
async fn runtime_plugin_from_entry(
|
||||
pub(crate) async fn runtime_plugin_from_entry(
|
||||
packages: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
entry: PluginRegistryEntry,
|
||||
|
||||
@ -13,19 +13,23 @@ use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath,
|
||||
StoreError,
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, PluginManifestBytes,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore,
|
||||
RemotePath, StoreError,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
||||
ProjectPath, RemoteRef, SessionId,
|
||||
AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
|
||||
PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
|
||||
PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef,
|
||||
RemovalOutcome, SessionId, StagedPluginPackage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::plugin::JsonPluginManifestValidator;
|
||||
use application::{
|
||||
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
|
||||
ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
|
||||
RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
PluginLayoutOrigin, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -158,6 +162,152 @@ impl IdGenerator for SeqIds {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_manifest() -> Vec<u8> {
|
||||
br#"{
|
||||
"ideaPluginManifestVersion": 1,
|
||||
"id": "dev.idea.android-plugin",
|
||||
"displayName": "Android",
|
||||
"version": "1.0.0",
|
||||
"engines": {"idea": ">=0.1.0 <1.0.0"},
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui"],
|
||||
"contributes": {
|
||||
"layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
|
||||
}
|
||||
}"#.to_vec()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginPackages {
|
||||
manifest: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl FakePluginPackages {
|
||||
fn new(manifest: Vec<u8>) -> Self {
|
||||
Self {
|
||||
manifest: Arc::new(Mutex::new(manifest)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for FakePluginPackages {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes {
|
||||
bytes: self.manifest.lock().unwrap().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
staged: StagedPluginPackage,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
Ok(PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: staged.root,
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Ok(RemovalOutcome::NotFound)
|
||||
}
|
||||
|
||||
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()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginRegistry {
|
||||
registry: Arc<Mutex<PluginRegistry>>,
|
||||
}
|
||||
|
||||
impl FakePluginRegistry {
|
||||
fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
|
||||
Self {
|
||||
registry: Arc::new(Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
lifecycle_state,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/plugin".to_owned(),
|
||||
},
|
||||
content_hash: ContentHash::new("abc123").unwrap(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FakePluginRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
Ok(self.registry.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||
*self.registry.lock().unwrap() = registry.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_layout(
|
||||
store: FakeStore,
|
||||
fs: FakeFs,
|
||||
ids: SeqIds,
|
||||
bus: SpyBus,
|
||||
registry: FakePluginRegistry,
|
||||
) -> CreateLayout {
|
||||
CreateLayout::new(
|
||||
Arc::new(store),
|
||||
Arc::new(fs),
|
||||
Arc::new(ids),
|
||||
Arc::new(bus),
|
||||
Arc::new(FakePluginPackages::new(plugin_manifest())),
|
||||
Arc::new(registry),
|
||||
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -803,11 +953,12 @@ async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) {
|
||||
#[tokio::test]
|
||||
async fn create_layout_appends_and_activates_it() {
|
||||
let (store, fs, bus) = mgmt_env(pid(30)).await;
|
||||
let create = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xABC)),
|
||||
Arc::new(bus.clone()),
|
||||
let create = create_layout(
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
SeqIds::new(0xABC),
|
||||
bus.clone(),
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
);
|
||||
let out = create
|
||||
.execute(CreateLayoutInput {
|
||||
@ -832,11 +983,12 @@ async fn create_layout_appends_and_activates_it() {
|
||||
#[tokio::test]
|
||||
async fn create_layout_rejects_empty_name() {
|
||||
let (store, fs, bus) = mgmt_env(pid(31)).await;
|
||||
let err = CreateLayout::new(
|
||||
Arc::new(store),
|
||||
Arc::new(fs),
|
||||
Arc::new(SeqIds::new(1)),
|
||||
Arc::new(bus),
|
||||
let err = create_layout(
|
||||
store,
|
||||
fs,
|
||||
SeqIds::new(1),
|
||||
bus,
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(31),
|
||||
@ -848,6 +1000,149 @@ async fn create_layout_rejects_empty_name() {
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_layout_plugin_validates_active_runtime_contribution_and_persists_root() {
|
||||
let (store, fs, bus) = mgmt_env(pid(131)).await;
|
||||
let create = create_layout(
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
SeqIds::new(0x141),
|
||||
bus,
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
);
|
||||
let out = create
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(131),
|
||||
name: "Android Health".to_owned(),
|
||||
kind: LayoutKind::Plugin {
|
||||
plugin_origin: PluginLayoutOrigin {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
},
|
||||
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs.clone()))
|
||||
.execute(ListLayoutsInput {
|
||||
project_id: pid(131),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.active_id, out.layout_id);
|
||||
assert_eq!(
|
||||
list.layouts[1].kind,
|
||||
LayoutKind::Plugin {
|
||||
plugin_origin: PluginLayoutOrigin {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
},
|
||||
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
||||
}
|
||||
);
|
||||
|
||||
let tree_json = active_tree_json(&fs);
|
||||
assert_eq!(tree_json["root"]["type"], "customPluginLayout");
|
||||
assert_eq!(
|
||||
tree_json["root"]["node"]["pluginId"],
|
||||
"dev.idea.android-plugin"
|
||||
);
|
||||
assert_eq!(
|
||||
tree_json["root"]["node"]["layoutType"],
|
||||
"idea-android.health"
|
||||
);
|
||||
assert_eq!(tree_json["root"]["node"]["state"]["deviceId"], "pixel-8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_layout_plugin_rejects_inactive_runtime_contribution() {
|
||||
let (store, fs, bus) = mgmt_env(pid(132)).await;
|
||||
let err = create_layout(
|
||||
store,
|
||||
fs,
|
||||
SeqIds::new(0x142),
|
||||
bus,
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Disabled),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(132),
|
||||
name: "Android Health".to_owned(),
|
||||
kind: LayoutKind::Plugin {
|
||||
plugin_origin: PluginLayoutOrigin {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
},
|
||||
state: serde_json::json!({}),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_plugin_layout_state_persists_opaque_state() {
|
||||
let (store, fs, bus) = mgmt_env(pid(133)).await;
|
||||
let create = create_layout(
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
SeqIds::new(0x143),
|
||||
bus.clone(),
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
);
|
||||
create
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(133),
|
||||
name: "Android Health".to_owned(),
|
||||
kind: LayoutKind::Plugin {
|
||||
plugin_origin: PluginLayoutOrigin {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
},
|
||||
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()))
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: pid(133),
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let target = match loaded.layout.root {
|
||||
LayoutNode::CustomPluginLayout(cell) => cell.id,
|
||||
other => panic!("expected custom plugin layout root, got {other:?}"),
|
||||
};
|
||||
|
||||
let out = MutateLayout::new(Arc::new(store), Arc::new(fs.clone()), Arc::new(bus))
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: pid(133),
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetPluginLayoutState {
|
||||
target,
|
||||
state: serde_json::json!({ "deviceId": "pixel-8", "healthy": true }),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match out.layout.root {
|
||||
LayoutNode::CustomPluginLayout(cell) => {
|
||||
assert_eq!(cell.state["healthy"], true);
|
||||
}
|
||||
other => panic!("expected custom plugin layout root, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
active_tree_json(&fs)["root"]["node"]["state"]["healthy"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_layout_changes_the_name() {
|
||||
let (store, fs, bus) = mgmt_env(pid(32)).await;
|
||||
@ -886,11 +1181,12 @@ async fn delete_layout_rejects_the_last_one() {
|
||||
async fn delete_active_layout_reassigns_active() {
|
||||
let (store, fs, bus) = mgmt_env(pid(34)).await;
|
||||
// Add a second layout (becomes active), then delete it → active falls back.
|
||||
let created = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xD)),
|
||||
Arc::new(bus.clone()),
|
||||
let created = create_layout(
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
SeqIds::new(0xD),
|
||||
bus.clone(),
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(34),
|
||||
@ -925,11 +1221,12 @@ async fn delete_active_layout_reassigns_active() {
|
||||
#[tokio::test]
|
||||
async fn set_active_layout_switches_and_load_follows() {
|
||||
let (store, fs, bus) = mgmt_env(pid(35)).await;
|
||||
let created = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xE)),
|
||||
Arc::new(bus.clone()),
|
||||
let created = create_layout(
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
SeqIds::new(0xE),
|
||||
bus.clone(),
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(35),
|
||||
|
||||
@ -1118,9 +1118,9 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
|
||||
|
||||
use application::{
|
||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
|
||||
LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput,
|
||||
LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput,
|
||||
};
|
||||
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
|
||||
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType};
|
||||
|
||||
/// Response DTO carrying a layout tree.
|
||||
///
|
||||
@ -1214,6 +1214,15 @@ pub enum LayoutOperationDto {
|
||||
#[serde(default)]
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Persist opaque plugin layout state.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SetPluginLayoutState {
|
||||
/// Custom plugin layout node.
|
||||
target: String,
|
||||
/// Opaque plugin-owned state.
|
||||
#[serde(default)]
|
||||
state: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperationDto {
|
||||
@ -1264,6 +1273,10 @@ impl LayoutOperationDto {
|
||||
target: parse_node_id(&target)?,
|
||||
conversation_id,
|
||||
},
|
||||
Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
|
||||
target: parse_node_id(&target)?,
|
||||
state,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1306,7 +1319,7 @@ pub struct LayoutInfoDto {
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Layout kind: `"terminal"` or `"gitGraph"`.
|
||||
/// Layout kind: `"terminal"`, `"gitGraph"` or `"plugin"`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
@ -1315,6 +1328,7 @@ impl From<LayoutInfo> for LayoutInfoDto {
|
||||
let kind = match info.kind {
|
||||
LayoutKind::Terminal => "terminal",
|
||||
LayoutKind::GitGraph => "gitGraph",
|
||||
LayoutKind::Plugin { .. } => "plugin",
|
||||
}
|
||||
.to_owned();
|
||||
Self {
|
||||
@ -1384,9 +1398,15 @@ pub struct CreateLayoutRequestDto {
|
||||
pub project_id: String,
|
||||
/// Display name for the new layout.
|
||||
pub name: String,
|
||||
/// Optional layout kind: `"terminal"` (default) or `"gitGraph"`.
|
||||
/// Optional layout kind: `"terminal"` (default), `"gitGraph"` or `"plugin"`.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
/// Plugin origin for `"plugin"` layouts.
|
||||
#[serde(default)]
|
||||
pub plugin_origin: Option<PluginLayoutOriginDto>,
|
||||
/// Opaque initial state for `"plugin"` layouts.
|
||||
#[serde(default)]
|
||||
pub state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CreateLayoutRequestDto {
|
||||
@ -1398,6 +1418,16 @@ impl CreateLayoutRequestDto {
|
||||
match self.kind.as_deref() {
|
||||
None | Some("terminal") => Ok(LayoutKind::Terminal),
|
||||
Some("gitGraph") => Ok(LayoutKind::GitGraph),
|
||||
Some("plugin") | Some("customPluginLayout") => {
|
||||
let origin = self.plugin_origin.clone().ok_or_else(|| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: "pluginOrigin is required for plugin layouts".to_owned(),
|
||||
})?;
|
||||
Ok(LayoutKind::Plugin {
|
||||
plugin_origin: origin.try_into()?,
|
||||
state: self.state.clone(),
|
||||
})
|
||||
}
|
||||
Some(other) => Err(ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("unknown layout kind: {other}"),
|
||||
@ -1406,6 +1436,35 @@ impl CreateLayoutRequestDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable provider identity for a plugin layout creation request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLayoutOriginDto {
|
||||
/// Provider plugin id.
|
||||
pub plugin_id: String,
|
||||
/// Layout type declared by the provider.
|
||||
pub layout_type: String,
|
||||
}
|
||||
|
||||
impl TryFrom<PluginLayoutOriginDto> for PluginLayoutOrigin {
|
||||
type Error = ErrorDto;
|
||||
|
||||
fn try_from(value: PluginLayoutOriginDto) -> Result<Self, Self::Error> {
|
||||
let plugin_id = PluginId::new(value.plugin_id).map_err(|e| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
let layout_type = PluginLayoutType::new(value.layout_type).map_err(|e| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
Ok(Self {
|
||||
plugin_id,
|
||||
layout_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `rename_layout`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -1536,6 +1536,9 @@ impl BackendCore {
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
));
|
||||
let rename_layout = Arc::new(RenameLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
|
||||
@ -603,6 +603,38 @@ impl LayoutTree {
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Replaces the opaque state of a plugin-provided custom layout leaf.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a custom plugin layout
|
||||
/// leaf in the tree.
|
||||
pub fn set_plugin_layout_state(
|
||||
&self,
|
||||
target: NodeId,
|
||||
state: serde_json::Value,
|
||||
) -> Result<Self, LayoutError> {
|
||||
let mut found = false;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::CustomPluginLayout(cell) = node {
|
||||
if cell.id == target {
|
||||
found = true;
|
||||
let mut cell = cell.clone();
|
||||
cell.state = state.clone();
|
||||
return LayoutNode::CustomPluginLayout(cell);
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
if !found {
|
||||
return Err(LayoutError::NodeNotFound(target));
|
||||
}
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Sets (or, with `None`, clears) the **engine session id** cache
|
||||
/// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of
|
||||
/// the current provider (id Claude `--resume`, UUID minté pour `--session-id`).
|
||||
|
||||
Reference in New Issue
Block a user