//! Use cases for ephemeral AI ticket editing assistants. use std::sync::Arc; use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer}; 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, profiles: Arc, contexts: Arc, environment: Arc, factory: Arc, structured: Arc, policies: Arc, events: Arc, } /// 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, profiles: Arc, contexts: Arc, environment: Arc, factory: Arc, structured: Arc, policies: Arc, events: Arc, ) -> Self { Self { issues, profiles, contexts, environment, 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 { 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 requester = ticket_assistant_requester(&input.project, input.issue_ref); let had_existing = self .structured .ticket_assistant_requester(input.issue_ref) .is_some(); let policy = AgentToolPolicy::new( vec![ "idea_ticket_read".to_owned(), "idea_ticket_update".to_owned(), "idea_ticket_update_status".to_owned(), "idea_ticket_update_priority".to_owned(), "idea_ticket_read_carnet".to_owned(), "idea_ticket_update_carnet".to_owned(), "idea_ticket_link".to_owned(), "idea_ticket_unlink".to_owned(), ], Some(input.issue_ref), true, ); self.policies.set_policy(requester.clone(), policy); let environment = match self .environment .prepare_ticket_assistant( &input.project, input.issue_ref, &profile, &prepared, &requester, ) .await { Ok(environment) => environment, Err(err) => { if !had_existing { self.policies.clear_policy(&requester); } return Err(AppError::from(err)); } }; let session = self .factory .start( &profile, &prepared, &environment.cwd, &SessionPlan::None, &environment.env, None, ) .await .map_err(|err| { if !had_existing { self.policies.clear_policy(&requester); } AppError::from(err) })?; let session_id = session.id(); if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) { if old_requester != requester { self.policies.clear_policy(&old_requester); } } let replaced = self.structured.insert_ticket_assistant( input.issue_ref, requester.clone(), Arc::clone(&session), ); 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, }) } } fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String { format!( "ticket-assistant:{}:{}", project.id.as_uuid().simple(), issue_ref.number().get() ) } /// Closes an ephemeral ticket assistant chat session. pub struct CloseTicketAssistant { structured: Arc, policies: Arc, events: Arc, } /// 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, policies: Arc, events: Arc, ) -> 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, profile_id: ProfileId, ) -> Result { profiles .list() .await? .into_iter() .find(|profile| profile.id == profile_id) .ok_or_else(|| AppError::NotFound(format!("profile {profile_id}"))) }