200 lines
6.1 KiB
Rust
200 lines
6.1 KiB
Rust
//! Use cases for ephemeral AI ticket editing assistants.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{AgentSessionFactory, SessionPlan};
|
|
use domain::AgentProfile;
|
|
use domain::{
|
|
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
|
|
IssueRef, IssueStore, ProfileId, ProfileStore, Project, SessionId,
|
|
};
|
|
|
|
use crate::terminal::StructuredSessions;
|
|
use crate::AppError;
|
|
|
|
/// Opens an ephemeral ticket assistant chat session.
|
|
pub struct OpenTicketAssistant {
|
|
issues: Arc<dyn IssueStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
contexts: Arc<dyn AssistantContextProvider>,
|
|
factory: Arc<dyn AgentSessionFactory>,
|
|
structured: Arc<StructuredSessions>,
|
|
policies: Arc<dyn AgentToolPolicyStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
/// Input for [`OpenTicketAssistant`].
|
|
pub struct OpenTicketAssistantInput {
|
|
/// Project owning the ticket.
|
|
pub project: Project,
|
|
/// Ticket reference to bind.
|
|
pub issue_ref: IssueRef,
|
|
/// Structured profile to use.
|
|
pub profile_id: ProfileId,
|
|
}
|
|
|
|
/// Output of [`OpenTicketAssistant`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OpenTicketAssistantOutput {
|
|
/// Live structured session id, to be driven by `agent_send`.
|
|
pub session_id: SessionId,
|
|
/// MCP requester identity bound to this assistant.
|
|
pub requester: String,
|
|
/// Bound ticket reference.
|
|
pub issue_ref: IssueRef,
|
|
}
|
|
|
|
impl OpenTicketAssistant {
|
|
/// Builds the use case.
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[must_use]
|
|
pub fn new(
|
|
issues: Arc<dyn IssueStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
contexts: Arc<dyn AssistantContextProvider>,
|
|
factory: Arc<dyn AgentSessionFactory>,
|
|
structured: Arc<StructuredSessions>,
|
|
policies: Arc<dyn AgentToolPolicyStore>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
issues,
|
|
profiles,
|
|
contexts,
|
|
factory,
|
|
structured,
|
|
policies,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes the open flow.
|
|
///
|
|
/// Reopening an already-live assistant for the same ticket replaces the old
|
|
/// session after the fresh one has started. This guarantees the assistant sees
|
|
/// the latest ticket content/profile and keeps the invariant "one assistant per
|
|
/// ticket" without leaving a gap if the new launch fails.
|
|
pub async fn execute(
|
|
&self,
|
|
input: OpenTicketAssistantInput,
|
|
) -> Result<OpenTicketAssistantOutput, AppError> {
|
|
let issue = self
|
|
.issues
|
|
.get_by_ref(&input.project.root, input.issue_ref)
|
|
.await?;
|
|
let profile = find_profile(&self.profiles, input.profile_id).await?;
|
|
if !self.factory.supports(&profile) {
|
|
return Err(AppError::Invalid(format!(
|
|
"profile {} cannot start a structured ticket assistant",
|
|
input.profile_id
|
|
)));
|
|
}
|
|
let prepared = self
|
|
.contexts
|
|
.prepare_ticket_assistant_context(&input.project, &issue)
|
|
.await
|
|
.map_err(|e| AppError::Store(e.to_string()))?;
|
|
let session = self
|
|
.factory
|
|
.start(
|
|
&profile,
|
|
&prepared,
|
|
&input.project.root,
|
|
&SessionPlan::None,
|
|
&[],
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(AppError::from)?;
|
|
let session_id = session.id();
|
|
let requester = session_id.to_string();
|
|
let policy = AgentToolPolicy::new(
|
|
vec![
|
|
"idea_ticket_read".to_owned(),
|
|
"idea_ticket_update".to_owned(),
|
|
"idea_ticket_update_carnet".to_owned(),
|
|
],
|
|
Some(input.issue_ref),
|
|
true,
|
|
);
|
|
|
|
if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) {
|
|
self.policies.clear_policy(&old_requester);
|
|
}
|
|
let replaced = self.structured.insert_ticket_assistant(
|
|
input.issue_ref,
|
|
requester.clone(),
|
|
Arc::clone(&session),
|
|
);
|
|
self.policies.set_policy(requester.clone(), policy);
|
|
if let Some(old) = replaced {
|
|
let _ = old.shutdown().await;
|
|
}
|
|
self.events.publish(DomainEvent::TicketAssistantOpened {
|
|
issue_ref: input.issue_ref,
|
|
profile_id: input.profile_id,
|
|
});
|
|
|
|
Ok(OpenTicketAssistantOutput {
|
|
session_id,
|
|
requester,
|
|
issue_ref: input.issue_ref,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Closes an ephemeral ticket assistant chat session.
|
|
pub struct CloseTicketAssistant {
|
|
structured: Arc<StructuredSessions>,
|
|
policies: Arc<dyn AgentToolPolicyStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
/// Input for [`CloseTicketAssistant`].
|
|
pub struct CloseTicketAssistantInput {
|
|
/// Ticket reference whose assistant should be closed.
|
|
pub issue_ref: IssueRef,
|
|
}
|
|
|
|
impl CloseTicketAssistant {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
structured: Arc<StructuredSessions>,
|
|
policies: Arc<dyn AgentToolPolicyStore>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
structured,
|
|
policies,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes the close flow.
|
|
pub async fn execute(&self, input: CloseTicketAssistantInput) -> Result<(), AppError> {
|
|
let (session, requester) = self
|
|
.structured
|
|
.remove_ticket_assistant(input.issue_ref)
|
|
.ok_or_else(|| AppError::NotFound(format!("ticket assistant {}", input.issue_ref)))?;
|
|
self.policies.clear_policy(&requester);
|
|
session.shutdown().await.map_err(AppError::from)?;
|
|
self.events.publish(DomainEvent::TicketAssistantClosed {
|
|
issue_ref: input.issue_ref,
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn find_profile(
|
|
profiles: &Arc<dyn ProfileStore>,
|
|
profile_id: ProfileId,
|
|
) -> Result<AgentProfile, AppError> {
|
|
profiles
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.find(|profile| profile.id == profile_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("profile {profile_id}")))
|
|
}
|