feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)
Checkpoint de travail — QA formelle encore à venir (après reset Codex). `cargo build` workspace OK, tests ticket/issue verts (domaine, store, use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé « ticket » côté MCP/UI. - T1 domaine : entité Issue (statut, priorité, liens, carnet), events, ids, ports. - T2 infra : store FS Markdown + allocator de références #N. - T3 application : use cases Issue (create/read/list/update/status/ priority/carnet/link/unlink/assign) + erreurs dédiées. - T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au total), mappés vers les use cases Issue. - T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue + câblage state.rs. Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets, non traitée ici. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -9,6 +9,7 @@ use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||
RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
use domain::IssueStoreError;
|
||||
use domain::{AgentId, NodeId};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
@ -128,6 +129,17 @@ impl From<StoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IssueStoreError> for AppError {
|
||||
fn from(e: IssueStoreError) -> Self {
|
||||
match e {
|
||||
IssueStoreError::NotFound => Self::NotFound("issue".to_owned()),
|
||||
IssueStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()),
|
||||
IssueStoreError::Invalid(message) => Self::Invalid(message),
|
||||
IssueStoreError::Store(message) => Self::Store(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
|
||||
774
crates/application/src/issues/mod.rs
Normal file
774
crates/application/src/issues/mod.rs
Normal file
@ -0,0 +1,774 @@
|
||||
//! Issue use cases.
|
||||
//!
|
||||
//! The domain/code term is `Issue` throughout this layer.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
|
||||
use domain::{
|
||||
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId,
|
||||
IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator,
|
||||
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`CreateIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateIssueInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue title.
|
||||
pub title: String,
|
||||
/// Issue description Markdown.
|
||||
pub description: String,
|
||||
/// Initial priority.
|
||||
pub priority: IssuePriority,
|
||||
/// Initial status.
|
||||
pub status: IssueStatus,
|
||||
/// Initial issue links.
|
||||
pub links: Vec<IssueLink>,
|
||||
/// Initially assigned agents.
|
||||
pub assigned_agent_ids: Vec<AgentId>,
|
||||
/// Actor creating the issue.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Output of [`CreateIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateIssueOutput {
|
||||
/// Created issue.
|
||||
pub issue: Issue,
|
||||
}
|
||||
|
||||
/// Creates an issue, allocating a non-reused project-local number.
|
||||
pub struct CreateIssue {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
allocator: Arc<dyn IssueNumberAllocator>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateIssue {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
allocator: Arc<dyn IssueNumberAllocator>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
allocator,
|
||||
contexts,
|
||||
ids,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on invariant, manifest validation or store failure.
|
||||
pub async fn execute(&self, input: CreateIssueInput) -> Result<CreateIssueOutput, AppError> {
|
||||
validate_agents(&self.contexts, &input.project, &input.assigned_agent_ids).await?;
|
||||
let number = self.allocator.allocate_next(&input.project.root).await?;
|
||||
let issue_ref = IssueRef::from(number);
|
||||
for link in &input.links {
|
||||
if link.target != issue_ref {
|
||||
self.issues
|
||||
.get_by_ref(&input.project.root, link.target)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let agent_refs = input
|
||||
.assigned_agent_ids
|
||||
.into_iter()
|
||||
.map(|agent_id| AgentIssueRef {
|
||||
agent_id,
|
||||
role: AgentIssueRole::Assigned,
|
||||
})
|
||||
.collect();
|
||||
let issue = Issue::new(
|
||||
IssueId::from_uuid(self.ids.new_uuid()),
|
||||
number,
|
||||
input.title,
|
||||
MarkdownDoc::new(input.description),
|
||||
input.status,
|
||||
input.priority,
|
||||
MarkdownDoc::default(),
|
||||
input.links,
|
||||
agent_refs,
|
||||
input.actor,
|
||||
now(&self.clock),
|
||||
)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.issues.create(&input.project.root, &issue).await?;
|
||||
self.events.publish(DomainEvent::IssueCreated {
|
||||
issue_id: issue.id,
|
||||
issue_ref: issue.reference(),
|
||||
});
|
||||
Ok(CreateIssueOutput { issue })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ReadIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
/// Output of [`ReadIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueOutput {
|
||||
/// Issue.
|
||||
pub issue: Issue,
|
||||
}
|
||||
|
||||
/// Reads an issue.
|
||||
pub struct ReadIssue {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
}
|
||||
|
||||
impl ReadIssue {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||
Self { issues }
|
||||
}
|
||||
|
||||
/// Executes read.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on store failure.
|
||||
pub async fn execute(&self, input: ReadIssueInput) -> Result<ReadIssueOutput, AppError> {
|
||||
Ok(ReadIssueOutput {
|
||||
issue: self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ReadIssueCarnet::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueCarnetInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
/// Output of [`ReadIssueCarnet::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueCarnetOutput {
|
||||
/// Carnet projection.
|
||||
pub carnet: IssueCarnet,
|
||||
}
|
||||
|
||||
/// Reads an issue carnet.
|
||||
pub struct ReadIssueCarnet {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
}
|
||||
|
||||
impl ReadIssueCarnet {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||
Self { issues }
|
||||
}
|
||||
|
||||
/// Executes carnet read.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on store failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadIssueCarnetInput,
|
||||
) -> Result<ReadIssueCarnetOutput, AppError> {
|
||||
Ok(ReadIssueCarnetOutput {
|
||||
carnet: self
|
||||
.issues
|
||||
.read_carnet(&input.project.root, input.issue_ref)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ListIssues::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListIssuesInput {
|
||||
/// Project owning the issues.
|
||||
pub project: Project,
|
||||
/// Filter.
|
||||
pub filter: IssueListFilter,
|
||||
}
|
||||
|
||||
/// Output of [`ListIssues::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListIssuesOutput {
|
||||
/// Matching issue index entries.
|
||||
pub issues: Vec<IssueIndexEntry>,
|
||||
}
|
||||
|
||||
/// Lists issues.
|
||||
pub struct ListIssues {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
}
|
||||
|
||||
impl ListIssues {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||
Self { issues }
|
||||
}
|
||||
|
||||
/// Executes list.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on store failure.
|
||||
pub async fn execute(&self, input: ListIssuesInput) -> Result<ListIssuesOutput, AppError> {
|
||||
Ok(ListIssuesOutput {
|
||||
issues: self.issues.list(&input.project.root, input.filter).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateIssueInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Expected optimistic version.
|
||||
pub expected_version: IssueVersion,
|
||||
/// Optional title replacement.
|
||||
pub title: Option<String>,
|
||||
/// Optional description replacement.
|
||||
pub description: Option<String>,
|
||||
/// Optional status replacement.
|
||||
pub status: Option<IssueStatus>,
|
||||
/// Optional priority replacement.
|
||||
pub priority: Option<IssuePriority>,
|
||||
/// Optional full assigned-agent replacement.
|
||||
pub assigned_agent_ids: Option<Vec<AgentId>>,
|
||||
/// Actor updating the issue.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateIssueOutput {
|
||||
/// Updated issue.
|
||||
pub issue: Issue,
|
||||
}
|
||||
|
||||
/// Updates issue fields with optimistic concurrency.
|
||||
pub struct UpdateIssue {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateIssue {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
contexts,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes update.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on invariant, manifest validation or store failure.
|
||||
pub async fn execute(&self, input: UpdateIssueInput) -> Result<UpdateIssueOutput, AppError> {
|
||||
if let Some(agent_ids) = &input.assigned_agent_ids {
|
||||
validate_agents(&self.contexts, &input.project, agent_ids).await?;
|
||||
}
|
||||
let current = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let old_status = current.status;
|
||||
let old_priority = current.priority;
|
||||
let old_assigned = assigned_set(¤t);
|
||||
let assigned_agent_ids = input.assigned_agent_ids.clone();
|
||||
let updated = current
|
||||
.mutate(input.actor, now(&self.clock), |issue| {
|
||||
if let Some(title) = input.title {
|
||||
issue.title = title;
|
||||
}
|
||||
if let Some(description) = input.description {
|
||||
issue.description = MarkdownDoc::new(description);
|
||||
}
|
||||
if let Some(status) = input.status {
|
||||
issue.status = status;
|
||||
}
|
||||
if let Some(priority) = input.priority {
|
||||
issue.priority = priority;
|
||||
}
|
||||
if let Some(agent_ids) = assigned_agent_ids {
|
||||
replace_assigned_agents(issue, agent_ids);
|
||||
}
|
||||
})
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.issues
|
||||
.update(&input.project.root, &updated, input.expected_version)
|
||||
.await?;
|
||||
publish_update_events(
|
||||
&self.events,
|
||||
&updated,
|
||||
old_status,
|
||||
old_priority,
|
||||
old_assigned,
|
||||
);
|
||||
Ok(UpdateIssueOutput { issue: updated })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateIssueCarnet::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateIssueCarnetInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Expected optimistic version.
|
||||
pub expected_version: IssueVersion,
|
||||
/// Carnet Markdown.
|
||||
pub carnet: String,
|
||||
/// Actor updating the carnet.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateIssueCarnet::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateIssueCarnetOutput {
|
||||
/// Carnet projection.
|
||||
pub carnet: IssueCarnet,
|
||||
}
|
||||
|
||||
/// Updates an issue carnet.
|
||||
pub struct UpdateIssueCarnet {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateIssueCarnet {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes carnet update.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on store failure or conflict.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateIssueCarnetInput,
|
||||
) -> Result<UpdateIssueCarnetOutput, AppError> {
|
||||
let carnet = self
|
||||
.issues
|
||||
.write_carnet(
|
||||
&input.project.root,
|
||||
input.issue_ref,
|
||||
MarkdownDoc::new(input.carnet),
|
||||
input.actor,
|
||||
now(&self.clock),
|
||||
input.expected_version,
|
||||
)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::IssueCarnetUpdated {
|
||||
issue_ref: input.issue_ref,
|
||||
version: carnet.version,
|
||||
});
|
||||
Ok(UpdateIssueCarnetOutput { carnet })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`LinkIssues::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LinkIssuesInput {
|
||||
/// Project owning the issues.
|
||||
pub project: Project,
|
||||
/// Source issue.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Target issue.
|
||||
pub target: IssueRef,
|
||||
/// Link kind.
|
||||
pub kind: IssueLinkKind,
|
||||
/// Expected optimistic version.
|
||||
pub expected_version: IssueVersion,
|
||||
/// Actor updating the issue.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Input for [`UnlinkIssues::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnlinkIssuesInput {
|
||||
/// Project owning the issues.
|
||||
pub project: Project,
|
||||
/// Source issue.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Target issue.
|
||||
pub target: IssueRef,
|
||||
/// Optional link kind. `None` removes every link from `issue_ref` to `target`.
|
||||
pub kind: Option<IssueLinkKind>,
|
||||
/// Expected optimistic version.
|
||||
pub expected_version: IssueVersion,
|
||||
/// Actor updating the issue.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Output for link mutations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LinkIssuesOutput {
|
||||
/// Updated issue.
|
||||
pub issue: Issue,
|
||||
}
|
||||
|
||||
/// Adds an issue link.
|
||||
pub struct LinkIssues {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl LinkIssues {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes link creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on invariant or store failure.
|
||||
pub async fn execute(&self, input: LinkIssuesInput) -> Result<LinkIssuesOutput, AppError> {
|
||||
// Ensure target exists and source self-link is caught before persisting.
|
||||
self.issues
|
||||
.get_by_ref(&input.project.root, input.target)
|
||||
.await?;
|
||||
let current = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let updated = current
|
||||
.mutate(input.actor, now(&self.clock), |issue| {
|
||||
let link = IssueLink {
|
||||
target: input.target,
|
||||
kind: input.kind,
|
||||
};
|
||||
if !issue.links.contains(&link) {
|
||||
issue.links.push(link);
|
||||
}
|
||||
})
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.issues
|
||||
.update(&input.project.root, &updated, input.expected_version)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::IssueLinked {
|
||||
issue_ref: input.issue_ref,
|
||||
target: input.target,
|
||||
kind: input.kind,
|
||||
version: updated.version,
|
||||
});
|
||||
Ok(LinkIssuesOutput { issue: updated })
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes an issue link.
|
||||
pub struct UnlinkIssues {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UnlinkIssues {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes link removal.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on store failure.
|
||||
pub async fn execute(&self, input: UnlinkIssuesInput) -> Result<LinkIssuesOutput, AppError> {
|
||||
let current = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let removed: Vec<IssueLinkKind> = current
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.target == input.target && input.kind.map_or(true, |kind| link.kind == kind)
|
||||
})
|
||||
.map(|link| link.kind)
|
||||
.collect();
|
||||
let updated = current
|
||||
.mutate(input.actor, now(&self.clock), |issue| {
|
||||
issue.links.retain(|link| {
|
||||
!(link.target == input.target
|
||||
&& input.kind.map_or(true, |kind| link.kind == kind))
|
||||
});
|
||||
})
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.issues
|
||||
.update(&input.project.root, &updated, input.expected_version)
|
||||
.await?;
|
||||
for kind in removed {
|
||||
self.events.publish(DomainEvent::IssueUnlinked {
|
||||
issue_ref: input.issue_ref,
|
||||
target: input.target,
|
||||
kind,
|
||||
version: updated.version,
|
||||
});
|
||||
}
|
||||
Ok(LinkIssuesOutput { issue: updated })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`AssignIssueAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssignIssueAgentInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Agent to assign or unassign.
|
||||
pub agent_id: AgentId,
|
||||
/// Whether the agent must be assigned.
|
||||
pub assigned: bool,
|
||||
/// Expected optimistic version.
|
||||
pub expected_version: IssueVersion,
|
||||
/// Actor updating the issue.
|
||||
pub actor: IssueActor,
|
||||
}
|
||||
|
||||
/// Output of [`AssignIssueAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssignIssueAgentOutput {
|
||||
/// Updated issue.
|
||||
pub issue: Issue,
|
||||
}
|
||||
|
||||
/// Assigns or unassigns an agent on an issue.
|
||||
pub struct AssignIssueAgent {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl AssignIssueAgent {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
contexts,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes assignment mutation.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on manifest validation or store failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: AssignIssueAgentInput,
|
||||
) -> Result<AssignIssueAgentOutput, AppError> {
|
||||
validate_agents(&self.contexts, &input.project, &[input.agent_id]).await?;
|
||||
let current = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let updated =
|
||||
current
|
||||
.mutate(input.actor, now(&self.clock), |issue| {
|
||||
if input.assigned {
|
||||
if !issue.agent_refs.iter().any(|r| {
|
||||
r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned
|
||||
}) {
|
||||
issue.agent_refs.push(AgentIssueRef {
|
||||
agent_id: input.agent_id,
|
||||
role: AgentIssueRole::Assigned,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
issue.agent_refs.retain(|r| {
|
||||
!(r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned)
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.issues
|
||||
.update(&input.project.root, &updated, input.expected_version)
|
||||
.await?;
|
||||
if input.assigned {
|
||||
self.events.publish(DomainEvent::IssueAgentAssigned {
|
||||
issue_ref: input.issue_ref,
|
||||
agent_id: input.agent_id,
|
||||
version: updated.version,
|
||||
});
|
||||
} else {
|
||||
self.events.publish(DomainEvent::IssueAgentUnassigned {
|
||||
issue_ref: input.issue_ref,
|
||||
agent_id: input.agent_id,
|
||||
version: updated.version,
|
||||
});
|
||||
}
|
||||
Ok(AssignIssueAgentOutput { issue: updated })
|
||||
}
|
||||
}
|
||||
|
||||
fn now(clock: &Arc<dyn Clock>) -> u64 {
|
||||
clock.now_millis().max(0) as u64
|
||||
}
|
||||
|
||||
async fn validate_agents(
|
||||
contexts: &Arc<dyn AgentContextStore>,
|
||||
project: &Project,
|
||||
agent_ids: &[AgentId],
|
||||
) -> Result<(), AppError> {
|
||||
if agent_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let manifest = contexts.load_manifest(project).await?;
|
||||
let known: HashSet<AgentId> = manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| entry.agent_id)
|
||||
.collect();
|
||||
for agent_id in agent_ids {
|
||||
if !known.contains(agent_id) {
|
||||
return Err(AppError::NotFound(format!("agent {agent_id}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assigned_set(issue: &Issue) -> HashSet<AgentId> {
|
||||
issue
|
||||
.agent_refs
|
||||
.iter()
|
||||
.filter(|reference| reference.role == AgentIssueRole::Assigned)
|
||||
.map(|reference| reference.agent_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec<AgentId>) {
|
||||
issue
|
||||
.agent_refs
|
||||
.retain(|reference| reference.role != AgentIssueRole::Assigned);
|
||||
for agent_id in agent_ids {
|
||||
issue.agent_refs.push(AgentIssueRef {
|
||||
agent_id,
|
||||
role: AgentIssueRole::Assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_update_events(
|
||||
events: &Arc<dyn EventBus>,
|
||||
issue: &Issue,
|
||||
old_status: IssueStatus,
|
||||
old_priority: IssuePriority,
|
||||
old_assigned: HashSet<AgentId>,
|
||||
) {
|
||||
let issue_ref = issue.reference();
|
||||
events.publish(DomainEvent::IssueUpdated {
|
||||
issue_ref,
|
||||
version: issue.version,
|
||||
});
|
||||
if issue.status != old_status {
|
||||
events.publish(DomainEvent::IssueStatusChanged {
|
||||
issue_ref,
|
||||
status: issue.status,
|
||||
version: issue.version,
|
||||
});
|
||||
}
|
||||
if issue.priority != old_priority {
|
||||
events.publish(DomainEvent::IssuePriorityChanged {
|
||||
issue_ref,
|
||||
priority: issue.priority,
|
||||
version: issue.version,
|
||||
});
|
||||
}
|
||||
let new_assigned = assigned_set(issue);
|
||||
for agent_id in new_assigned.difference(&old_assigned) {
|
||||
events.publish(DomainEvent::IssueAgentAssigned {
|
||||
issue_ref,
|
||||
agent_id: *agent_id,
|
||||
version: issue.version,
|
||||
});
|
||||
}
|
||||
for agent_id in old_assigned.difference(&new_assigned) {
|
||||
events.publish(DomainEvent::IssueAgentUnassigned {
|
||||
issue_ref,
|
||||
agent_id: *agent_id,
|
||||
version: issue.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ pub mod embedder;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod health;
|
||||
pub mod issues;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
@ -66,6 +67,14 @@ pub use git::{
|
||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||
};
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use issues::{
|
||||
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput,
|
||||
CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput,
|
||||
ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
|
||||
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
|
||||
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
|
||||
UpdateIssueOutput,
|
||||
};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
|
||||
Reference in New Issue
Block a user