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 {

View File

@ -579,6 +579,16 @@ pub enum DomainEventDto {
#[serde(skip_serializing_if = "Option::is_none")]
orchestrator: Option<String>,
},
/// The project's global context was written directly.
#[serde(rename_all = "camelCase")]
ProjectContextUpdated {
/// The project whose global context changed.
project_id: String,
/// Writer party (`"user"` or agent id).
by: String,
/// Epoch-milliseconds of the write.
at_ms: i64,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto {
project_id: project_id.to_string(),
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
},
DomainEvent::ProjectContextUpdated {
project_id,
by,
at_ms,
} => Self::ProjectContextUpdated {
project_id: project_id.to_string(),
by: conversation_party_wire(*by),
at_ms: *at_ms,
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},
@ -1426,6 +1445,28 @@ mod tests {
);
}
#[test]
fn project_context_updated_relays_writer_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
let writer = agent(2);
let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated {
project_id,
by: ConversationParty::agent(writer),
at_ms: 987_654,
});
assert_eq!(
serde_json::to_value(&dto).unwrap(),
json!({
"type": "projectContextUpdated",
"projectId": project_id.to_string(),
"by": writer.to_string(),
"atMs": 987654,
})
);
}
#[test]
fn background_completion_relays_rendezvous_context_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));

View File

@ -11,6 +11,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
@ -35,7 +36,7 @@ use application::{
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
@ -49,7 +50,7 @@ use application::{
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
@ -64,10 +65,11 @@ use domain::ports::{
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -91,15 +93,15 @@ use infrastructure::{
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
@ -1093,6 +1095,8 @@ pub struct BackendCore {
pub update_project_permissions: Arc<UpdateProjectPermissions>,
/// Update one agent permission override.
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
/// Update one agent effort override.
pub update_agent_effort: Arc<UpdateAgentEffort>,
/// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
/// Read the project system permission document.
@ -1142,6 +1146,8 @@ pub struct BackendCore {
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
/// Public plugin structured config document facade.
pub plugin_config_documents: Arc<PluginConfigDocuments>,
/// Public plugin-owned storage facade.
pub plugin_storage_access: Arc<PluginStorageAccess>,
/// Public plugin project-structure query use case.
pub query_project_structure: Arc<QueryProjectStructure>,
/// Public plugin command/task facade.
@ -1416,11 +1422,13 @@ impl BackendCore {
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
let plugin_validator =
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
let plugin_manifest_validator =
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
let plugin_mcp_supervisor_port =
@ -2178,6 +2186,7 @@ impl BackendCore {
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
@ -2425,6 +2434,7 @@ impl BackendCore {
));
let uninstall_plugin = Arc::new(UninstallPlugin::new(
Arc::clone(&plugin_package_store),
Arc::clone(&plugin_storage_store),
Arc::clone(&plugin_registry_store),
Arc::clone(&events_port),
Arc::clone(&plugin_mcp_supervisor_port),
@ -2448,6 +2458,10 @@ impl BackendCore {
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
.with_events(Arc::clone(&events_port)),
);
let plugin_storage_access = Arc::new(PluginStorageAccess::new(
Arc::clone(&plugin_storage_store),
Arc::clone(&plugin_registry_store),
));
let query_project_structure = Arc::new(QueryProjectStructure::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
@ -2718,6 +2732,13 @@ impl BackendCore {
Arc::clone(&fs_port),
Arc::clone(&clock) as Arc<dyn Clock>,
)),
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
Arc::clone(&file_guard),
Arc::clone(&contexts_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
Arc::clone(&clock) as Arc<dyn Clock>,
)),
read_memory: Arc::new(ReadMemory::new(
Arc::clone(&file_guard),
Arc::clone(&memory_store_port),
@ -2976,6 +2997,7 @@ impl BackendCore {
get_project_permissions,
update_project_permissions,
update_agent_permissions,
update_agent_effort,
resolve_agent_permissions,
get_project_system_permissions,
update_project_system_permissions,
@ -3045,6 +3067,7 @@ impl BackendCore {
reconcile_plugin_mcp_servers,
plugin_workspace_access,
plugin_config_documents,
plugin_storage_access,
query_project_structure,
plugin_command_tasks,
plugin_toolchain_diagnostics,
@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}
@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests {
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
// -----------------------------------------------------------------------
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
use domain::conversation::ConversationParty;
use domain::memory::{
@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests {
Arc::new(FakeFs),
Arc::new(FixedClock),
)),
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
Arc::clone(&file_guard),
Arc::new(contexts.clone()),
Arc::new(FakeFs),
Arc::new(NoopBus),
Arc::new(FixedClock),
)),
read_memory: Arc::new(ReadMemory::new(
Arc::clone(&file_guard),
Arc::clone(&memory) as Arc<dyn MemoryStore>,
@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}