Files
IdeA/crates/application/src/template/usecases.rs

652 lines
22 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, McpToolPermissionStore, StoreError,
SystemPermissionStore, TemplateStore,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry,
MarkdownDoc, McpToolPolicy, ProfileId, Project, SystemPermissionSet, 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,
/// Optional default agent name for agents created from this template.
pub default_agent_name: Option<String>,
/// Optional agent-level system permission override applied at creation time.
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool permission override applied at creation time.
pub default_mcp_tool_policy: Option<McpToolPolicy>,
/// Whether agents created from this template are cross-project contactable.
pub default_contactable: bool,
}
/// 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.clone(),
MarkdownDoc::new(input.content),
input.default_profile_id,
)
.and_then(|template| {
template.with_metadata(
input.name,
input.default_profile_id,
input.default_agent_name,
input.default_system_permissions,
input.default_mcp_tool_policy,
input.default_contactable,
)
})
.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. `None` leaves content and version untouched.
pub content: Option<String>,
/// New display name. `None` leaves the current value.
pub name: Option<String>,
/// New default runtime profile. `None` leaves the current value.
pub default_profile_id: Option<ProfileId>,
/// New default agent name. Outer `None` leaves the current value; inner
/// `None` clears the default so agent creation falls back to template name.
pub default_agent_name: Option<Option<String>>,
/// New default system permissions. Outer `None` leaves the current value;
/// inner `None` preserves project inheritance for future agents.
pub default_system_permissions: Option<Option<SystemPermissionSet>>,
/// New default MCP tool policy. Outer `None` leaves the current value; inner
/// `None` preserves project inheritance for future agents.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
/// New default contactable flag. `None` leaves the current value.
pub default_contactable: Option<bool>,
}
/// 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 mut updated = if let Some(content) = input.content {
current.with_updated_content(MarkdownDoc::new(content))
} else {
current
};
updated = updated
.with_metadata(
input.name.unwrap_or_else(|| updated.name.clone()),
input
.default_profile_id
.unwrap_or(updated.default_profile_id),
input
.default_agent_name
.unwrap_or_else(|| updated.default_agent_name.clone()),
input
.default_system_permissions
.unwrap_or_else(|| updated.default_system_permissions.clone()),
input
.default_mcp_tool_policy
.unwrap_or_else(|| updated.default_mcp_tool_policy.clone()),
input
.default_contactable
.unwrap_or(updated.default_contactable),
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
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>,
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
mcp_tool_permissions: Option<Arc<dyn McpToolPermissionStore>>,
}
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,
system_permissions: None,
mcp_tool_permissions: None,
}
}
/// Injects the project system permission store used to project template
/// agent overrides at creation time.
#[must_use]
pub fn with_system_permission_store(mut self, store: Arc<dyn SystemPermissionStore>) -> Self {
self.system_permissions = Some(store);
self
}
/// Injects the project MCP tool permission store used to project template
/// agent overrides at creation time.
#[must_use]
pub fn with_mcp_tool_permission_store(
mut self,
store: Arc<dyn McpToolPermissionStore>,
) -> Self {
self.mcp_tool_permissions = Some(store);
self
}
/// 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
.or_else(|| template.default_agent_name.clone())
.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()))?
.with_contactable(template.default_contactable);
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.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?;
if let Some(permissions) = template.default_system_permissions.clone() {
if let Some(store) = &self.system_permissions {
let mut doc = store.load_system_permissions(&input.project).await?;
doc.set_agent_permissions(agent.id, Some(permissions));
store.save_system_permissions(&input.project, &doc).await?;
}
}
if let Some(policy) = template.default_mcp_tool_policy.clone() {
if let Some(store) = &self.mcp_tool_permissions {
let mut doc = store.load_mcp_tool_permissions(&input.project).await?;
doc.agents
.retain(|override_| override_.agent_id != agent.id);
doc.agents
.push(domain::AgentMcpToolPolicyOverride::new(agent.id, policy));
store
.save_mcp_tool_permissions(&input.project, &doc)
.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::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.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),
})
}
}