Merge branch 'feature/issue-ticket-system' into feature/background-tasks-first-class

Intègre le système de tickets/issues V1 (validé QA vert de bout en bout,
mémoire tickets-v1-e2e-validated-qa) dans la branche des tâches de fond.
Conflits résolus : app-tauri/state.rs, domain/events.rs, domain/lib.rs, domain/ports.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:15:43 +02:00
38 changed files with 6539 additions and 45 deletions

View File

@ -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 {

View 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(&current);
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,
});
}
}

View File

@ -19,6 +19,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;
@ -71,6 +72,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,

View File

@ -0,0 +1,402 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{
AgentContextStore, Clock, EventBus, EventStream, IdGenerator, IssueNumberAllocator, IssueStore,
IssueStoreError, StoreError,
};
use domain::{
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry,
IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef,
};
use uuid::Uuid;
use application::{CreateIssue, CreateIssueInput, UpdateIssue, UpdateIssueInput};
#[derive(Default)]
struct FakeIssues {
issues: Mutex<HashMap<IssueRef, Issue>>,
conflict: Mutex<bool>,
}
#[async_trait]
impl IssueStore for FakeIssues {
async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
self.issues
.lock()
.unwrap()
.insert(issue.reference(), issue.clone());
Ok(())
}
async fn get_by_ref(
&self,
_root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<Issue, IssueStoreError> {
self.issues
.lock()
.unwrap()
.get(&issue_ref)
.cloned()
.ok_or(IssueStoreError::NotFound)
}
async fn list(
&self,
_root: &ProjectPath,
_filter: IssueListFilter,
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
Ok(self
.issues
.lock()
.unwrap()
.values()
.map(IssueIndexEntry::from)
.collect())
}
async fn update(
&self,
_root: &ProjectPath,
issue: &Issue,
expected_version: IssueVersion,
) -> Result<(), IssueStoreError> {
if *self.conflict.lock().unwrap() {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: issue.version,
});
}
let current = self.get_by_ref(_root, issue.reference()).await?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
self.issues
.lock()
.unwrap()
.insert(issue.reference(), issue.clone());
Ok(())
}
async fn read_carnet(
&self,
_root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<IssueCarnet, IssueStoreError> {
let issue = self.get_by_ref(_root, issue_ref).await?;
Ok(IssueCarnet {
issue_ref,
carnet: issue.carnet,
version: issue.version,
updated_by: issue.updated_by,
updated_at: issue.updated_at,
})
}
async fn write_carnet(
&self,
_root: &ProjectPath,
issue_ref: IssueRef,
carnet: MarkdownDoc,
actor: IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError> {
let issue = self.get_by_ref(_root, issue_ref).await?;
if issue.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: issue.version,
});
}
let updated = issue
.mutate(actor, now_ms, |i| i.carnet = carnet)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
self.update(_root, &updated, expected_version).await?;
self.read_carnet(_root, issue_ref).await
}
}
struct SeqAllocator(Mutex<u64>);
#[async_trait]
impl IssueNumberAllocator for SeqAllocator {
async fn allocate_next(&self, _root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
let mut next = self.0.lock().unwrap();
let number = IssueNumber::new(*next).unwrap();
*next += 1;
Ok(number)
}
}
#[derive(Clone)]
struct FakeContexts(AgentManifest);
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.0.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct SpyBus(Mutex<Vec<DomainEvent>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
1_234
}
}
struct FixedIds;
impl IdGenerator for FixedIds {
fn new_uuid(&self) -> Uuid {
Uuid::from_u128(42)
}
}
fn project() -> Project {
Project::new(
ProjectId::new_random(),
"IdeA",
ProjectPath::new("/tmp/idea").unwrap(),
RemoteRef::Local,
0,
)
.unwrap()
}
fn manifest(agent_id: AgentId) -> AgentManifest {
AgentManifest::new(
1,
vec![ManifestEntry::new(
agent_id,
"Dev",
"agents/dev.md",
ProfileId::new_random(),
None,
false,
None,
)
.unwrap()],
)
.unwrap()
}
#[tokio::test]
async fn create_issue_allocates_number_validates_agent_and_emits_event() {
let agent_id = AgentId::new_random();
let issues = Arc::new(FakeIssues::default());
let bus = Arc::new(SpyBus::default());
let uc = CreateIssue::new(
issues.clone(),
Arc::new(SeqAllocator(Mutex::new(1))),
Arc::new(FakeContexts(manifest(agent_id))),
Arc::new(FixedIds),
Arc::new(FixedClock),
bus.clone(),
);
let out = uc
.execute(CreateIssueInput {
project: project(),
title: "Ship issues".into(),
description: "Body".into(),
priority: IssuePriority::Critical,
status: IssueStatus::Open,
links: Vec::new(),
assigned_agent_ids: vec![agent_id],
actor: IssueActor::User,
})
.await
.unwrap();
assert_eq!(out.issue.reference().to_string(), "#1");
assert_eq!(
out.issue.id,
domain::IssueId::from_uuid(Uuid::from_u128(42))
);
assert_eq!(out.issue.created_at, 1_234);
assert!(matches!(
bus.events().as_slice(),
[DomainEvent::IssueCreated { issue_ref, .. }] if issue_ref.to_string() == "#1"
));
}
#[tokio::test]
async fn create_issue_rejects_unknown_assignee() {
let known = AgentId::new_random();
let unknown = AgentId::new_random();
let uc = CreateIssue::new(
Arc::new(FakeIssues::default()),
Arc::new(SeqAllocator(Mutex::new(1))),
Arc::new(FakeContexts(manifest(known))),
Arc::new(FixedIds),
Arc::new(FixedClock),
Arc::new(SpyBus::default()),
);
let err = uc
.execute(CreateIssueInput {
project: project(),
title: "Bad assignee".into(),
description: "Body".into(),
priority: IssuePriority::Medium,
status: IssueStatus::Open,
links: Vec::new(),
assigned_agent_ids: vec![unknown],
actor: IssueActor::User,
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
}
#[tokio::test]
async fn update_issue_propagates_expected_version_and_emits_status_event() {
let agent_id = AgentId::new_random();
let issues = Arc::new(FakeIssues::default());
let initial = Issue::new(
domain::IssueId::new_random(),
IssueNumber::new(1).unwrap(),
"Initial",
MarkdownDoc::new("Body"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
Vec::new(),
Vec::new(),
IssueActor::User,
1,
)
.unwrap();
issues.create(&project().root, &initial).await.unwrap();
let bus = Arc::new(SpyBus::default());
let uc = UpdateIssue::new(
issues,
Arc::new(FakeContexts(manifest(agent_id))),
Arc::new(FixedClock),
bus.clone(),
);
let out = uc
.execute(UpdateIssueInput {
project: project(),
issue_ref: initial.reference(),
expected_version: initial.version,
title: Some("Updated".into()),
description: None,
status: Some(IssueStatus::InProgress),
priority: None,
assigned_agent_ids: None,
actor: IssueActor::System,
})
.await
.unwrap();
assert_eq!(out.issue.version.get(), 2);
assert!(bus.events().iter().any(|event| matches!(
event,
DomainEvent::IssueStatusChanged {
status: IssueStatus::InProgress,
..
}
)));
}
#[tokio::test]
async fn update_issue_maps_version_conflict() {
let agent_id = AgentId::new_random();
let issues = Arc::new(FakeIssues::default());
let initial = Issue::new(
domain::IssueId::new_random(),
IssueNumber::new(1).unwrap(),
"Initial",
MarkdownDoc::new("Body"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
Vec::new(),
Vec::new(),
IssueActor::User,
1,
)
.unwrap();
issues.create(&project().root, &initial).await.unwrap();
*issues.conflict.lock().unwrap() = true;
let uc = UpdateIssue::new(
issues,
Arc::new(FakeContexts(manifest(agent_id))),
Arc::new(FixedClock),
Arc::new(SpyBus::default()),
);
let err = uc
.execute(UpdateIssueInput {
project: project(),
issue_ref: initial.reference(),
expected_version: initial.version,
title: Some("Updated".into()),
description: None,
status: None,
priority: None,
assigned_agent_ids: None,
actor: IssueActor::System,
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("version conflict"));
}