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

454
crates/domain/src/issue.rs Normal file
View File

@ -0,0 +1,454 @@
//! Issue domain model.
//!
//! Code uses `Issue` terminology exclusively to avoid colliding with the
//! inter-agent delegation model.
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, IssueId};
use crate::markdown::MarkdownDoc;
/// Sequential per-project issue number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IssueNumber(u64);
impl IssueNumber {
/// Builds an issue number. Values start at 1.
///
/// # Errors
/// [`IssueError::InvalidNumber`] when `value == 0`.
pub fn new(value: u64) -> Result<Self, IssueError> {
if value == 0 {
return Err(IssueError::InvalidNumber);
}
Ok(Self(value))
}
/// Returns the raw numeric value.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl std::fmt::Display for IssueNumber {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Human-friendly issue reference, formatted as `#<number>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct IssueRef(IssueNumber);
impl IssueRef {
/// Builds a reference from a validated number.
#[must_use]
pub const fn new(number: IssueNumber) -> Self {
Self(number)
}
/// Returns the referenced issue number.
#[must_use]
pub const fn number(self) -> IssueNumber {
self.0
}
}
impl std::fmt::Display for IssueRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}", self.0.get())
}
}
impl From<IssueNumber> for IssueRef {
fn from(number: IssueNumber) -> Self {
Self::new(number)
}
}
impl FromStr for IssueRef {
type Err = IssueError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let digits = raw
.strip_prefix('#')
.ok_or_else(|| IssueError::InvalidRef(raw.to_owned()))?;
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(IssueError::InvalidRef(raw.to_owned()));
}
let number = digits
.parse::<u64>()
.map_err(|_| IssueError::InvalidRef(raw.to_owned()))?;
Ok(Self(IssueNumber::new(number)?))
}
}
impl Serialize for IssueRef {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for IssueRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
/// Optimistic-concurrency version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IssueVersion(u64);
impl IssueVersion {
/// Initial version assigned to a newly-created issue.
pub const INITIAL: Self = Self(1);
/// Builds a version. Values start at 1.
///
/// # Errors
/// [`IssueError::InvalidVersion`] when `value == 0`.
pub fn new(value: u64) -> Result<Self, IssueError> {
if value == 0 {
return Err(IssueError::InvalidVersion);
}
Ok(Self(value))
}
/// Returns the raw numeric value.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
/// Returns the next optimistic version.
#[must_use]
pub const fn next(self) -> Self {
Self(self.0 + 1)
}
}
impl std::fmt::Display for IssueVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Issue lifecycle status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum IssueStatus {
/// Open and not started.
Open,
/// Being worked.
InProgress,
/// Ready for QA.
Qa,
/// Closed.
Closed,
}
/// Issue priority.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum IssuePriority {
/// Low priority.
Low,
/// Default priority.
Medium,
/// High priority.
High,
/// Critical priority.
Critical,
}
/// Relationship kind between two issues.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum IssueLinkKind {
/// Informational relation.
RelatesTo,
/// This issue blocks the target.
Blocks,
/// This issue is blocked by the target.
BlockedBy,
/// This issue duplicates the target.
Duplicates,
/// This issue depends on the target.
DependsOn,
}
/// Link from one issue to another.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueLink {
/// Target issue.
pub target: IssueRef,
/// Link kind.
pub kind: IssueLinkKind,
}
/// Role an agent has on an issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentIssueRole {
/// Assigned implementer.
Assigned,
/// Mentioned participant.
Mentioned,
/// Reviewer.
Reviewer,
/// Owner.
Owner,
}
/// Agent reference on an issue.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentIssueRef {
/// Agent id.
pub agent_id: AgentId,
/// Agent role.
pub role: AgentIssueRole,
}
/// Actor responsible for an issue mutation.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum IssueActor {
/// Human user.
User,
/// Agent actor.
Agent {
/// Agent id.
agent_id: AgentId,
},
/// System actor.
System,
}
/// Issue aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Issue {
/// Stable UUID.
pub id: IssueId,
/// Sequential per-project number.
pub number: IssueNumber,
/// Human-readable title.
pub title: String,
/// Markdown description.
pub description: MarkdownDoc,
/// Lifecycle status.
pub status: IssueStatus,
/// Priority.
pub priority: IssuePriority,
/// Editable issue-local knowledge.
pub carnet: MarkdownDoc,
/// Links to other issues.
pub links: Vec<IssueLink>,
/// Agent references.
pub agent_refs: Vec<AgentIssueRef>,
/// Creator.
pub created_by: IssueActor,
/// Last updater.
pub updated_by: IssueActor,
/// Creation time, epoch milliseconds.
pub created_at: u64,
/// Last update time, epoch milliseconds.
pub updated_at: u64,
/// Optimistic-concurrency version.
pub version: IssueVersion,
}
impl Issue {
/// Builds a new issue with version 1.
///
/// # Errors
/// [`IssueError`] when invariants are violated.
#[allow(clippy::too_many_arguments)]
pub fn new(
id: IssueId,
number: IssueNumber,
title: impl Into<String>,
description: MarkdownDoc,
status: IssueStatus,
priority: IssuePriority,
carnet: MarkdownDoc,
links: Vec<IssueLink>,
agent_refs: Vec<AgentIssueRef>,
actor: IssueActor,
now_ms: u64,
) -> Result<Self, IssueError> {
let issue = Self {
id,
number,
title: title.into(),
description,
status,
priority,
carnet,
links,
agent_refs,
created_by: actor.clone(),
updated_by: actor,
created_at: now_ms,
updated_at: now_ms,
version: IssueVersion::INITIAL,
};
issue.validate()?;
Ok(issue)
}
/// Rehydrates a persisted issue.
///
/// # Errors
/// [`IssueError`] when persisted data violates invariants.
pub fn rehydrate(issue: Self) -> Result<Self, IssueError> {
issue.validate()?;
Ok(issue)
}
/// Returns this issue as `#<number>`.
#[must_use]
pub const fn reference(&self) -> IssueRef {
IssueRef::new(self.number)
}
/// Validates invariants.
///
/// # Errors
/// [`IssueError`] when an invariant is violated.
pub fn validate(&self) -> Result<(), IssueError> {
IssueNumber::new(self.number.get())?;
IssueVersion::new(self.version.get())?;
if self.title.trim().is_empty() {
return Err(IssueError::EmptyTitle);
}
let own = self.reference();
if self.links.iter().any(|link| link.target == own) {
return Err(IssueError::SelfLink { reference: own });
}
Ok(())
}
/// Applies a mutation and increments the version.
///
/// # Errors
/// [`IssueError`] when the resulting issue violates invariants.
pub fn mutate(
mut self,
actor: IssueActor,
now_ms: u64,
f: impl FnOnce(&mut Self),
) -> Result<Self, IssueError> {
f(&mut self);
self.updated_by = actor;
self.updated_at = now_ms;
self.version = self.version.next();
self.validate()?;
Ok(self)
}
}
/// Carnet projection with its optimistic version.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCarnet {
/// Issue reference.
pub issue_ref: IssueRef,
/// Carnet Markdown body.
pub carnet: MarkdownDoc,
/// Current issue version.
pub version: IssueVersion,
/// Actor that last updated the carnet.
pub updated_by: IssueActor,
/// Last carnet update time, epoch milliseconds.
pub updated_at: u64,
}
/// Index row used by stores and list use cases.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueIndexEntry {
/// Issue reference.
pub issue_ref: IssueRef,
/// Relative path to the issue folder.
pub path: String,
/// Title.
pub title: String,
/// Status.
pub status: IssueStatus,
/// Priority.
pub priority: IssuePriority,
/// Assigned agent ids.
pub assigned_agent_ids: Vec<AgentId>,
/// Last update time.
pub updated_at: u64,
}
impl From<&Issue> for IssueIndexEntry {
fn from(issue: &Issue) -> Self {
Self {
issue_ref: issue.reference(),
path: issue.number.get().to_string(),
title: issue.title.clone(),
status: issue.status,
priority: issue.priority,
assigned_agent_ids: issue
.agent_refs
.iter()
.filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id)
.collect(),
updated_at: issue.updated_at,
}
}
}
/// Store-side list filter.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueListFilter {
/// Optional status filter.
pub status: Option<IssueStatus>,
/// Optional priority filter.
pub priority: Option<IssuePriority>,
/// Optional assigned agent filter.
pub assigned_agent_id: Option<AgentId>,
/// Optional case-insensitive text query.
pub text: Option<String>,
}
/// Domain errors for issue invariants and value objects.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IssueError {
/// Issue numbers are strictly positive.
#[error("issue number must be greater than zero")]
InvalidNumber,
/// Issue versions are strictly positive.
#[error("issue version must be greater than zero")]
InvalidVersion,
/// Issue title cannot be empty.
#[error("issue title cannot be empty")]
EmptyTitle,
/// Issue reference must be exactly `#<positive integer>`.
#[error("invalid issue reference: {0}")]
InvalidRef(String),
/// A link targets the same issue.
#[error("issue {reference} cannot link to itself")]
SelfLink {
/// Self reference.
reference: IssueRef,
},
}