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

@ -337,6 +337,47 @@ impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBi
}
}
/// Plugin-owned storage read/delete request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetDto {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
}
impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
fn from(value: PluginStorageGetDto) -> Self {
Self {
plugin_id: value.plugin_id,
key: value.key,
}
}
}
/// Plugin-owned storage write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetDto {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
/// JSON value to persist.
pub value: Value,
}
impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
fn from(value: PluginStorageSetDto) -> Self {
Self {
plugin_id: value.plugin_id,
key: value.key,
value: value.value,
}
}
}
/// Plugin structured config document read request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -2377,8 +2418,8 @@ use application::{
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
};
use domain::{
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
ProjectPermissions, SkillKind, TerminalSession,
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
};
/// One discoverable capability carried by an agent.
@ -2413,6 +2454,8 @@ pub struct AgentDto {
pub agent: Agent,
/// Resolved discoverable capabilities.
pub capabilities: Vec<AgentCapabilityDto>,
/// Whether this agent is the effective project orchestrator.
pub is_orchestrator: bool,
}
impl AgentDto {
@ -2422,6 +2465,7 @@ impl AgentDto {
Self {
agent,
capabilities: Vec::new(),
is_orchestrator: false,
}
}
}
@ -2443,6 +2487,7 @@ impl From<ListAgentsOutput> for AgentListDto {
.into_iter()
.map(AgentCapabilityDto::from)
.collect(),
is_orchestrator: entry.is_orchestrator,
})
.collect(),
)
@ -2512,6 +2557,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
#[serde(transparent)]
pub struct EffectivePermissionsDto(pub EffectivePermissions);
/// Response for resolving one agent's file/bash permissions.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentPermissionsResponseDto {
/// Resolved policy, or `null` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
/// Diagnostic report for agent-level allows shadowed by project defaults.
pub shadowed: PermissionShadowReport,
}
impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
Self {
effective: out.effective,
shadowed: out.shadowed,
}
}
}
/// Full project system permission document crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
@ -2767,6 +2831,18 @@ pub struct ChangeAgentProfileRequestDto {
pub cols: u16,
}
/// Request DTO for `update_agent_effort`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentEffortRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent whose effort override changes.
pub agent_id: String,
/// `null` clears the override.
pub effort: Option<EffortSelection>,
}
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
/// relaunched session when a live session was hot-swapped (absent otherwise).
#[derive(Debug, Clone, Serialize)]
@ -3997,10 +4073,16 @@ pub struct CreateSkillRequestDto {
pub project_id: String,
/// Display name.
pub name: String,
/// Optional one-line affordance description.
#[serde(default)]
pub description: Option<String>,
/// Initial Markdown content.
pub content: String,
/// Scope the skill is created in.
pub scope: SkillScope,
/// Capability nature. Missing legacy clients create workflow skills.
#[serde(default)]
pub kind: SkillKind,
}
/// Request DTO for `update_skill`.
@ -4703,7 +4785,7 @@ pub struct SpawnBackgroundCommandRequestDto {
mod tests {
use application::McpToolPermissionCatalogue;
use domain::mailbox::TicketId;
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
use serde_json::json;
use uuid::Uuid;
@ -4755,6 +4837,34 @@ mod tests {
);
}
#[test]
fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
let dto = ResolveAgentPermissionsResponseDto {
effective: None,
shadowed: PermissionShadowReport {
read: false,
write: false,
delete: false,
execute_bash: true,
fallback: true,
},
};
assert_eq!(
serde_json::to_value(dto).unwrap(),
json!({
"effective": null,
"shadowed": {
"read": false,
"write": false,
"delete": false,
"executeBash": true,
"fallback": true
}
})
);
}
#[test]
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
let path = PluginWorkspacePathDto {
@ -4868,6 +4978,42 @@ mod tests {
assert_eq!(input.value["enabled"], true);
}
#[test]
fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
let get = PluginStorageGetDto {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
};
assert_eq!(
serde_json::to_value(&get).unwrap(),
json!({
"pluginId": "dev.acme.gitgraph",
"key": "helloPlugin.launches"
})
);
let input: application::PluginStorageGetInput = get.into();
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
assert_eq!(input.key, "helloPlugin.launches");
let set = PluginStorageSetDto {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.enabled".to_owned(),
value: json!({"enabled": true}),
};
assert_eq!(
serde_json::to_value(&set).unwrap(),
json!({
"pluginId": "dev.acme.gitgraph",
"key": "helloPlugin.enabled",
"value": {"enabled": true}
})
);
let input: application::PluginStorageSetInput = set.into();
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
assert_eq!(input.key, "helloPlugin.enabled");
assert_eq!(input.value, json!({"enabled": true}));
}
#[test]
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
let listing = PluginWorkspaceDirectoryListingDto {