Files
IdeaSDK/crates/application/src/ticket_assistant.rs

298 lines
9.7 KiB
Rust

//! 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, EventBus,
IssueRef, IssueStore, NetworkPolicy, 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>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
}
/// 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>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
profiles,
contexts,
environment,
factory,
structured,
policies,
events,
system_permissions: None,
}
}
/// Wires the project system permission store used for assistant launches.
#[must_use]
pub fn with_system_permission_store(mut self, store: Arc<dyn SystemPermissionStore>) -> 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<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 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 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<Option<StructuredProviderLaunchPolicy>, 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<Option<NetworkPolicy>, 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))
}
}
fn codex_network_access(network: Option<NetworkPolicy>) -> 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<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}")))
}