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:
2026-07-22 07:37:03 +02:00
parent 47aacc6da9
commit bb35641715
24 changed files with 4304 additions and 41 deletions

View File

@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
use crate::plugin::{PluginId, PluginLayoutType};
/// Direction of a [`SplitContainer`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -160,6 +161,24 @@ pub struct GridContainer {
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.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
@ -170,6 +189,8 @@ pub enum LayoutNode {
Split(SplitContainer),
/// A spreadsheet-style grid.
Grid(GridContainer),
/// A plugin-provided custom layout leaf.
CustomPluginLayout(CustomPluginLayoutCell),
}
/// The root of a layout (one per tab).
@ -663,6 +684,7 @@ impl LayoutTree {
out.push((leaf.id, agent));
}
}
LayoutNode::CustomPluginLayout(_) => {}
LayoutNode::Split(split) => {
for child in &split.children {
walk(&child.node, out);
@ -770,6 +792,7 @@ impl LayoutTree {
match n {
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
LayoutNode::Leaf(_) => None,
LayoutNode::CustomPluginLayout(_) => None,
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)),
}
@ -784,6 +807,7 @@ impl LayoutTree {
match node {
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
LayoutNode::Leaf(_) => None,
LayoutNode::CustomPluginLayout(_) => None,
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)),
}
@ -797,6 +821,7 @@ impl LayoutTree {
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
let rebuilt = match node {
LayoutNode::Leaf(_) => node.clone(),
LayoutNode::CustomPluginLayout(_) => node.clone(),
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
id: split.id,
direction: split.direction,
@ -844,6 +869,7 @@ fn validate_node(
}
Ok(())
}
LayoutNode::CustomPluginLayout(_) => Ok(()),
LayoutNode::Split(split) => {
if split.children.is_empty() {
return Err(LayoutError::EmptySplit);