298 lines
9.7 KiB
Rust
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}")))
|
|
}
|