Ajoute le MCP dédié à l'édition de templates : catalogue et classification des templates (mcp/templates.rs infrastructure + app-tauri), use cases et provider (application/template), enforcement de la policy des tools (mod.rs, server.rs, tools.rs), avec la parité côté chemin OpenAI-compatible (openai_tools.rs x2). Lots B1 (catalogue/classification), B2 (use cases/provider) et B3 (enforcement policy) livrés en un seul commit cohérent. QA vert (seul l'échec de bind loopback #80, connu et non-régression, écarté). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
538 lines
17 KiB
Rust
538 lines
17 KiB
Rust
//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7).
|
|
//!
|
|
//! Two concerns live here:
|
|
//! - **Templates** (global IDE store): CRUD + monotonic versioning
|
|
//! ([`CreateTemplate`], [`UpdateTemplate`], [`ListTemplates`], [`DeleteTemplate`]).
|
|
//! - **Template → agent link**: instantiating an agent from a template
|
|
//! ([`CreateAgentFromTemplate`]), detecting when a synchronized agent is behind
|
|
//! its template ([`DetectAgentDrift`]), and applying the update
|
|
//! ([`SyncAgentWithTemplate`]).
|
|
//!
|
|
//! Every use case talks only to ports ([`TemplateStore`], [`AgentContextStore`],
|
|
//! [`IdGenerator`], [`EventBus`]).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore};
|
|
use domain::{
|
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry,
|
|
MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion,
|
|
};
|
|
|
|
use crate::agent::unique_md_path;
|
|
use crate::error::AppError;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`CreateTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateTemplateInput {
|
|
/// Display name.
|
|
pub name: String,
|
|
/// Initial Markdown content.
|
|
pub content: String,
|
|
/// Default runtime profile for agents created from this template.
|
|
pub default_profile_id: ProfileId,
|
|
}
|
|
|
|
/// Output of [`CreateTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateTemplateOutput {
|
|
/// The created template (at [`TemplateVersion::INITIAL`]).
|
|
pub template: AgentTemplate,
|
|
}
|
|
|
|
/// Creates a template in the global store at the initial version.
|
|
pub struct CreateTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl CreateTemplate {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(templates: Arc<dyn TemplateStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
|
Self { templates, ids }
|
|
}
|
|
|
|
/// Executes creation.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::Invalid`] if the name is empty,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: CreateTemplateInput,
|
|
) -> Result<CreateTemplateOutput, AppError> {
|
|
let id = TemplateId::from_uuid(self.ids.new_uuid());
|
|
let template = AgentTemplate::new(
|
|
id,
|
|
input.name,
|
|
MarkdownDoc::new(input.content),
|
|
input.default_profile_id,
|
|
)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.templates.save(&template).await?;
|
|
Ok(CreateTemplateOutput { template })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UpdateTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`UpdateTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateTemplateInput {
|
|
/// Template to update.
|
|
pub template_id: TemplateId,
|
|
/// New Markdown content.
|
|
pub content: String,
|
|
}
|
|
|
|
/// Output of [`UpdateTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateTemplateOutput {
|
|
/// The updated template (version bumped by one).
|
|
pub template: AgentTemplate,
|
|
}
|
|
|
|
/// Updates a template's content and **bumps its version** (monotonic, §8.1),
|
|
/// announcing [`DomainEvent::TemplateUpdated`] so drift can be re-evaluated.
|
|
pub struct UpdateTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl UpdateTemplate {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(templates: Arc<dyn TemplateStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { templates, events }
|
|
}
|
|
|
|
/// Executes the update.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the template is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: UpdateTemplateInput,
|
|
) -> Result<UpdateTemplateOutput, AppError> {
|
|
let current = self.templates.get(input.template_id).await?;
|
|
let updated = current.with_updated_content(MarkdownDoc::new(input.content));
|
|
self.templates.save(&updated).await?;
|
|
self.events.publish(DomainEvent::TemplateUpdated {
|
|
template_id: updated.id,
|
|
version: updated.version,
|
|
});
|
|
Ok(UpdateTemplateOutput { template: updated })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ReadTemplate / ListTemplates / DeleteTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`ReadTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReadTemplateInput {
|
|
/// Template to read.
|
|
pub template_id: TemplateId,
|
|
}
|
|
|
|
/// Output of [`ReadTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReadTemplateOutput {
|
|
/// The requested template.
|
|
pub template: AgentTemplate,
|
|
}
|
|
|
|
/// Reads one template from the global store.
|
|
pub struct ReadTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
}
|
|
|
|
impl ReadTemplate {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
|
Self { templates }
|
|
}
|
|
|
|
/// Reads one template.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the template is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: ReadTemplateInput) -> Result<ReadTemplateOutput, AppError> {
|
|
Ok(ReadTemplateOutput {
|
|
template: self.templates.get(input.template_id).await?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Output of [`ListTemplates::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListTemplatesOutput {
|
|
/// All templates in the global store.
|
|
pub templates: Vec<AgentTemplate>,
|
|
}
|
|
|
|
/// Lists the templates in the global store.
|
|
pub struct ListTemplates {
|
|
templates: Arc<dyn TemplateStore>,
|
|
}
|
|
|
|
impl ListTemplates {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
|
Self { templates }
|
|
}
|
|
|
|
/// Lists templates.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self) -> Result<ListTemplatesOutput, AppError> {
|
|
Ok(ListTemplatesOutput {
|
|
templates: self.templates.list().await?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`DeleteTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteTemplateInput {
|
|
/// Template to delete.
|
|
pub template_id: TemplateId,
|
|
}
|
|
|
|
/// Deletes a template from the global store.
|
|
///
|
|
/// Agents previously created from it keep their `.md` (their `origin` still
|
|
/// references the now-absent template; drift detection simply finds nothing to
|
|
/// compare against).
|
|
pub struct DeleteTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
}
|
|
|
|
impl DeleteTemplate {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
|
Self { templates }
|
|
}
|
|
|
|
/// Deletes the template.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the template is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: DeleteTemplateInput) -> Result<(), AppError> {
|
|
self.templates.delete(input.template_id).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateAgentFromTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`CreateAgentFromTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateAgentFromTemplateInput {
|
|
/// The project that owns the agent.
|
|
pub project: Project,
|
|
/// Source template.
|
|
pub template_id: TemplateId,
|
|
/// Optional agent name; defaults to the template's name.
|
|
pub name: Option<String>,
|
|
/// Whether the agent tracks the template (`true` ⇒ future syncs apply).
|
|
pub synchronized: bool,
|
|
}
|
|
|
|
/// Output of [`CreateAgentFromTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateAgentFromTemplateOutput {
|
|
/// The created agent.
|
|
pub agent: Agent,
|
|
}
|
|
|
|
/// Instantiates a project agent from a template: copies the template's
|
|
/// `content_md` into the agent's `.md`, links the origin to the template at its
|
|
/// current version, and records the manifest entry.
|
|
pub struct CreateAgentFromTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl CreateAgentFromTemplate {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
templates,
|
|
contexts,
|
|
ids,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes creation from a template.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the template is unknown,
|
|
/// - [`AppError::Invalid`] if the resulting agent/manifest is invalid,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: CreateAgentFromTemplateInput,
|
|
) -> Result<CreateAgentFromTemplateOutput, AppError> {
|
|
let template = self.templates.get(input.template_id).await?;
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
|
|
let name = input.name.unwrap_or_else(|| template.name.clone());
|
|
let id = AgentId::from_uuid(self.ids.new_uuid());
|
|
let md_path = unique_md_path(&name, &manifest);
|
|
let origin = AgentOrigin::FromTemplate {
|
|
template_id: template.id,
|
|
synced_template_version: template.version,
|
|
};
|
|
let agent = Agent::new(
|
|
id,
|
|
name,
|
|
md_path,
|
|
template.default_profile_id,
|
|
origin,
|
|
input.synchronized,
|
|
)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
|
|
let mut entries = manifest.entries;
|
|
entries.push(ManifestEntry::from_agent(&agent));
|
|
let manifest = AgentManifest::new(manifest.version, entries)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
|
|
// Seed the agent context with the template content.
|
|
self.contexts
|
|
.write_context(&input.project, &agent.id, &template.content_md)
|
|
.await?;
|
|
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project.id,
|
|
});
|
|
|
|
Ok(CreateAgentFromTemplateOutput { agent })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DetectAgentDrift
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One drifting agent: its template moved ahead of the agent's synced version.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AgentDrift {
|
|
/// The drifting agent.
|
|
pub agent_id: AgentId,
|
|
/// Version the agent is currently synced to.
|
|
pub from: TemplateVersion,
|
|
/// Version available from the template.
|
|
pub to: TemplateVersion,
|
|
}
|
|
|
|
/// Input for [`DetectAgentDrift::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DetectAgentDriftInput {
|
|
/// The project whose agents to check.
|
|
pub project: Project,
|
|
}
|
|
|
|
/// Output of [`DetectAgentDrift::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DetectAgentDriftOutput {
|
|
/// Agents whose template has a newer version (only `synchronized` ones).
|
|
pub drifts: Vec<AgentDrift>,
|
|
}
|
|
|
|
/// Detects which synchronized agents are behind their template
|
|
/// (`template.version > synced_template_version`, §8.2), announcing
|
|
/// [`DomainEvent::AgentDriftDetected`] for each.
|
|
pub struct DetectAgentDrift {
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl DetectAgentDrift {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
templates,
|
|
contexts,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Computes the drift set.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure (a *deleted* template is not an
|
|
/// error — that agent simply has nothing to drift against).
|
|
pub async fn execute(
|
|
&self,
|
|
input: DetectAgentDriftInput,
|
|
) -> Result<DetectAgentDriftOutput, AppError> {
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let mut drifts = Vec::new();
|
|
for entry in &manifest.entries {
|
|
// Only synchronized, template-backed agents can drift.
|
|
if !entry.synchronized {
|
|
continue;
|
|
}
|
|
let (Some(template_id), Some(synced)) =
|
|
(entry.template_id, entry.synced_template_version)
|
|
else {
|
|
continue;
|
|
};
|
|
let template = match self.templates.get(template_id).await {
|
|
Ok(t) => t,
|
|
Err(StoreError::NotFound) => continue,
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
if template.version > synced {
|
|
let drift = AgentDrift {
|
|
agent_id: entry.agent_id,
|
|
from: synced,
|
|
to: template.version,
|
|
};
|
|
self.events.publish(DomainEvent::AgentDriftDetected {
|
|
agent_id: drift.agent_id,
|
|
from: drift.from,
|
|
to: drift.to,
|
|
});
|
|
drifts.push(drift);
|
|
}
|
|
}
|
|
Ok(DetectAgentDriftOutput { drifts })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SyncAgentWithTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`SyncAgentWithTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SyncAgentWithTemplateInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent to bring up to date.
|
|
pub agent_id: AgentId,
|
|
}
|
|
|
|
/// Output of [`SyncAgentWithTemplate::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SyncAgentWithTemplateOutput {
|
|
/// Whether an update was applied (`false` for a non-synchronized or
|
|
/// scratch agent — those are intentionally left untouched, §8.4).
|
|
pub synced: bool,
|
|
/// The version the agent is now at, when a sync happened.
|
|
pub version: Option<TemplateVersion>,
|
|
}
|
|
|
|
/// Applies a template update to a synchronized agent: **replaces** the agent's
|
|
/// `.md` with the template content (the context of a synchronized agent is
|
|
/// "owned" by the template, §8.3) and records the new synced version. Agents
|
|
/// that are not `synchronized` (or are `scratch`) are left untouched.
|
|
pub struct SyncAgentWithTemplate {
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl SyncAgentWithTemplate {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
templates: Arc<dyn TemplateStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
templates,
|
|
contexts,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes the sync for one agent.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent or its template is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: SyncAgentWithTemplateInput,
|
|
) -> Result<SyncAgentWithTemplateOutput, AppError> {
|
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let idx = manifest
|
|
.entries
|
|
.iter()
|
|
.position(|e| e.agent_id == input.agent_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
let entry = &manifest.entries[idx];
|
|
|
|
// Non-synchronized / scratch agents are never auto-updated (§8.4).
|
|
let Some(template_id) = entry.template_id.filter(|_| entry.synchronized) else {
|
|
return Ok(SyncAgentWithTemplateOutput {
|
|
synced: false,
|
|
version: None,
|
|
});
|
|
};
|
|
|
|
let template = self.templates.get(template_id).await?;
|
|
manifest.entries[idx].synced_template_version = Some(template.version);
|
|
|
|
// Persist the manifest (revalidated) and overwrite the agent context.
|
|
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
self.contexts
|
|
.write_context(&input.project, &input.agent_id, &template.content_md)
|
|
.await?;
|
|
|
|
self.events.publish(DomainEvent::AgentSynced {
|
|
agent_id: input.agent_id,
|
|
to: template.version,
|
|
});
|
|
|
|
Ok(SyncAgentWithTemplateOutput {
|
|
synced: true,
|
|
version: Some(template.version),
|
|
})
|
|
}
|
|
}
|