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:
2026-07-02 22:34:04 +02:00
parent a9653bc417
commit 8de7be01a8
22 changed files with 4395 additions and 50 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

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