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>
775 lines
22 KiB
Rust
775 lines
22 KiB
Rust
//! 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,
|
|
});
|
|
}
|
|
}
|