//! Use cases for ephemeral AI ticket editing assistants. use std::sync::Arc; use domain::ports::{ AgentSessionFactory, SessionPlan, StructuredProviderLaunchPolicy, StructuredSessionEnvironmentPreparer, SystemPermissionStore, }; use domain::profile::StructuredAdapter; use domain::AgentProfile; use domain::{ AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EffectivePermissions, EventBus, IssueRef, IssueStore, NetworkPolicy, PermissionStore, 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, permissions: Option>, system_permissions: Option>, } /// 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, permissions: None, system_permissions: None, } } /// Wires the project agent permission store used for assistant launches. /// /// Ticket assistants are not agents, so only project defaults are resolved; /// agent overrides are deliberately ignored. #[must_use] pub fn with_permission_store(mut self, store: Arc) -> Self { self.permissions = Some(store); self } /// Wires the project system permission store used for assistant launches. #[must_use] pub fn with_system_permission_store(mut self, store: Arc) -> Self { self.system_permissions = Some(store); self } /// 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 permissions = self .resolve_project_default_permissions(&input.project) .await?; 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, permissions.as_ref(), ) .await { Ok(environment) => environment, Err(err) => { if !had_existing { self.policies.clear_policy(&requester); } return Err(AppError::from(err)); } }; let structured_policy = self .build_ticket_assistant_structured_policy(&input.project, &profile, &environment) .await?; let session = self .factory .start( &profile, &prepared, &environment.cwd, &SessionPlan::None, Some(&requester), &environment.env, None, structured_policy .as_ref() .or(environment.structured_policy.as_ref()), ) .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, }) } async fn build_ticket_assistant_structured_policy( &self, project: &Project, profile: &AgentProfile, environment: &domain::ports::StructuredSessionEnvironment, ) -> Result, AppError> { if profile.structured_adapter != Some(StructuredAdapter::Codex) { return Ok(environment.structured_policy.clone()); } let network = self.resolve_project_default_network(project).await?; Ok(Some(StructuredProviderLaunchPolicy::Codex { sandbox_mode: "workspace-write".to_owned(), writable_roots: vec![project.root.as_str().to_owned()], network_access: codex_network_access(network), })) } async fn resolve_project_default_network( &self, project: &Project, ) -> Result, AppError> { let Some(store) = &self.system_permissions else { return Ok(None); }; let doc = store.load_system_permissions(project).await?; Ok(doc.project_default.and_then(|set| set.network)) } async fn resolve_project_default_permissions( &self, project: &Project, ) -> Result, AppError> { let Some(store) = &self.permissions else { return Ok(None); }; let doc = store.load_permissions(project).await?; Ok(domain::permission::resolve( doc.project_defaults.as_ref(), None, )) } } fn codex_network_access(network: Option) -> bool { matches!(network, Some(NetworkPolicy::Allow)) } 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}"))) }