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

@ -0,0 +1,576 @@
//! Filesystem issue store.
//!
//! Project-scoped issues live under `<root>/.ideai/tickets/`, one directory per
//! issue number. The Markdown files are the source of truth; `index.json` is a
//! reconstructible projection kept in sync after writes.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use domain::{
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath,
};
const IDEAI_DIR: &str = ".ideai";
const ISSUES_DIR: &str = "tickets";
const ISSUE_FILE: &str = "issue.md";
const CARNET_FILE: &str = "carnet.md";
const INDEX_FILE: &str = "index.json";
const COUNTER_FILE: &str = "counter.json";
const COUNTER_LOCK: &str = "counter.lock";
const INDEX_VERSION: u32 = 1;
/// Filesystem-backed issue store.
#[derive(Debug, Clone, Default)]
pub struct FsIssueStore;
impl FsIssueStore {
/// Builds a store.
#[must_use]
pub const fn new() -> Self {
Self
}
}
/// Filesystem-backed issue number allocator.
#[derive(Debug, Clone, Default)]
pub struct FsIssueNumberAllocator;
impl FsIssueNumberAllocator {
/// Builds an allocator.
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CounterDoc {
next_number: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
issues: Vec<IssueIndexEntry>,
}
fn issue_root(root: &ProjectPath) -> PathBuf {
PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(ISSUES_DIR)
}
fn issue_dir(root: &ProjectPath, number: IssueNumber) -> PathBuf {
issue_root(root).join(number.get().to_string())
}
fn issue_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
issue_dir(root, number).join(ISSUE_FILE)
}
fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
issue_dir(root, number).join(CARNET_FILE)
}
fn index_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(INDEX_FILE)
}
fn counter_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(COUNTER_FILE)
}
fn counter_lock_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(COUNTER_LOCK)
}
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> {
let parent = path
.parent()
.ok_or_else(|| IssueStoreError::Store("path has no parent".to_owned()))?;
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
let tmp = path.with_extension("tmp");
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
tokio::fs::rename(&tmp, path).await.map_err(io_error)
}
async fn read_string(path: &Path) -> Result<String, IssueStoreError> {
let bytes = tokio::fs::read(path).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
IssueStoreError::NotFound
} else {
io_error(err)
}
})?;
String::from_utf8(bytes).map_err(|err| IssueStoreError::Store(err.to_string()))
}
fn io_error(err: std::io::Error) -> IssueStoreError {
IssueStoreError::Store(err.to_string())
}
async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
let number = issue_ref.number();
let issue_text = read_string(&issue_path(root, number)).await?;
let mut issue = parse_issue_doc(&issue_text)?;
let carnet = match read_string(&carnet_path(root, number)).await {
Ok(text) => parse_carnet_doc(&text)?.carnet,
Err(IssueStoreError::NotFound) => MarkdownDoc::default(),
Err(err) => return Err(err),
};
issue.carnet = carnet;
Issue::rehydrate(issue).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
async fn save_issue(root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
let dir = issue_dir(root, issue.number);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
write_atomic(&dir.join(ISSUE_FILE), render_issue_doc(issue).as_bytes()).await?;
write_atomic(&dir.join(CARNET_FILE), render_carnet_doc(issue).as_bytes()).await
}
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
let dir = issue_root(root);
let mut rows = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_index(root, &rows).await?;
return Ok(rows);
}
Err(err) => return Err(io_error(err)),
};
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
let Ok(file_type) = entry.file_type().await else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let Ok(number) = name.parse::<u64>() else {
continue;
};
let Ok(number) = IssueNumber::new(number) else {
continue;
};
if let Ok(issue) = load_issue(root, IssueRef::from(number)).await {
rows.push(IssueIndexEntry::from(&issue));
}
}
rows.sort_by_key(|row| row.issue_ref.number().get());
write_index(root, &rows).await?;
Ok(rows)
}
async fn read_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
match tokio::fs::read(index_path(root)).await {
Ok(bytes) => {
let doc: IndexDoc = serde_json::from_slice(&bytes)
.map_err(|err| IssueStoreError::Store(err.to_string()))?;
Ok(doc.issues)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
Err(err) => Err(io_error(err)),
}
}
async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(), IssueStoreError> {
let doc = IndexDoc {
version: INDEX_VERSION,
issues: rows.to_vec(),
};
let bytes =
serde_json::to_vec_pretty(&doc).map_err(|err| IssueStoreError::Store(err.to_string()))?;
write_atomic(&index_path(root), &bytes).await
}
fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
if filter.status.is_some_and(|status| row.status != status) {
return false;
}
if filter
.priority
.is_some_and(|priority| row.priority != priority)
{
return false;
}
if let Some(agent_id) = filter.assigned_agent_id {
if !row.assigned_agent_ids.contains(&agent_id) {
return false;
}
}
if let Some(text) = filter
.text
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
row.title
.to_ascii_lowercase()
.contains(&text.to_ascii_lowercase())
} else {
true
}
}
#[async_trait]
impl IssueStore for FsIssueStore {
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
issue
.validate()
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
if tokio::fs::try_exists(issue_path(root, issue.number))
.await
.map_err(io_error)?
{
return Err(IssueStoreError::Invalid(format!(
"issue {} already exists",
issue.reference()
)));
}
save_issue(root, issue).await?;
rebuild_index(root).await?;
Ok(())
}
async fn get_by_ref(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<Issue, IssueStoreError> {
load_issue(root, issue_ref).await
}
async fn list(
&self,
root: &ProjectPath,
filter: IssueListFilter,
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
let rows = read_index(root).await?;
if filter
.text
.as_ref()
.is_some_and(|text| !text.trim().is_empty())
{
// Text search may need description/carnet, so fall back to source files.
let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase();
let base_filter = IssueListFilter {
text: None,
..filter
};
let mut out = Vec::new();
for row in rows {
if !filter_matches(&row, &base_filter) {
continue;
}
if row.title.to_ascii_lowercase().contains(&needle) {
out.push(row);
continue;
}
if let Ok(issue) = load_issue(root, row.issue_ref).await {
if issue_contains(&issue, &needle) {
out.push(row);
}
}
}
return Ok(out);
}
let mut rows = rows;
rows.retain(|row| filter_matches(row, &filter));
Ok(rows)
}
async fn update(
&self,
root: &ProjectPath,
issue: &Issue,
expected_version: IssueVersion,
) -> Result<(), IssueStoreError> {
issue
.validate()
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
let current = load_issue(root, issue.reference()).await?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
save_issue(root, issue).await?;
rebuild_index(root).await?;
Ok(())
}
async fn read_carnet(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<IssueCarnet, IssueStoreError> {
let text = read_string(&carnet_path(root, issue_ref.number())).await?;
parse_carnet_doc(&text)
}
async fn write_carnet(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
carnet: MarkdownDoc,
actor: IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError> {
let current = load_issue(root, issue_ref).await?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
let updated = current
.mutate(actor, now_ms, |issue| issue.carnet = carnet)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
save_issue(root, &updated).await?;
rebuild_index(root).await?;
self.read_carnet(root, issue_ref).await
}
}
#[async_trait]
impl IssueNumberAllocator for FsIssueNumberAllocator {
async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
let dir = issue_root(root);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let lock_path = counter_lock_path(root);
let mut lock = None;
for _ in 0..50 {
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
.await
{
Ok(file) => {
lock = Some(file);
break;
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(err) => return Err(io_error(err)),
}
}
let Some(mut lock_file) = lock else {
return Err(IssueStoreError::Store(
"issue number allocator lock timed out".to_owned(),
));
};
lock_file.write_all(b"locked\n").await.map_err(io_error)?;
let result = async {
let path = counter_path(root);
let current = match tokio::fs::read(&path).await {
Ok(bytes) => {
serde_json::from_slice::<CounterDoc>(&bytes)
.map_err(|err| IssueStoreError::Store(err.to_string()))?
.next_number
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => 1,
Err(err) => return Err(io_error(err)),
};
let allocated = IssueNumber::new(current)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
let next = CounterDoc {
next_number: current + 1,
};
let bytes = serde_json::to_vec_pretty(&next)
.map_err(|err| IssueStoreError::Store(err.to_string()))?;
write_atomic(&path, &bytes).await?;
Ok(allocated)
}
.await;
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
}
fn issue_contains(issue: &Issue, needle: &str) -> bool {
issue
.description
.as_str()
.to_ascii_lowercase()
.contains(needle)
|| issue.carnet.as_str().to_ascii_lowercase().contains(needle)
}
fn render_issue_doc(issue: &Issue) -> String {
format!(
"---\n\
id: {}\n\
number: {}\n\
title: {}\n\
status: {}\n\
priority: {}\n\
links: {}\n\
agentRefs: {}\n\
createdBy: {}\n\
updatedBy: {}\n\
createdAt: {}\n\
updatedAt: {}\n\
version: {}\n\
---\n{}",
json_string(&issue.id.to_string()),
issue.number.get(),
json_string(&issue.title),
json_value(&issue.status),
json_value(&issue.priority),
json_value(&issue.links),
json_value(&issue.agent_refs),
json_value(&issue.created_by),
json_value(&issue.updated_by),
issue.created_at,
issue.updated_at,
issue.version.get(),
issue.description.as_str()
)
}
fn render_carnet_doc(issue: &Issue) -> String {
format!(
"---\n\
issueRef: {}\n\
version: {}\n\
updatedBy: {}\n\
updatedAt: {}\n\
---\n{}",
json_string(&issue.reference().to_string()),
issue.version.get(),
json_value(&issue.updated_by),
issue.updated_at,
issue.carnet.as_str()
)
}
fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
let (fm, body) = split_frontmatter(text)?;
let map = parse_frontmatter_map(fm)?;
let id_raw: String = parse_json_field(&map, "id")?;
let id = IssueId::from_uuid(
uuid::Uuid::parse_str(&id_raw).map_err(|err| IssueStoreError::Invalid(err.to_string()))?,
);
let number = IssueNumber::new(parse_plain_u64(&map, "number")?)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
let title: String = parse_json_field(&map, "title")?;
let status: IssueStatus = parse_json_field(&map, "status")?;
let priority: IssuePriority = parse_json_field(&map, "priority")?;
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
let created_at = parse_plain_u64(&map, "createdAt")?;
let updated_at = parse_plain_u64(&map, "updatedAt")?;
let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
Issue::rehydrate(Issue {
id,
number,
title,
description: MarkdownDoc::new(body),
status,
priority,
carnet: MarkdownDoc::default(),
links,
agent_refs,
created_by,
updated_by,
created_at,
updated_at,
version,
})
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn parse_carnet_doc(text: &str) -> Result<IssueCarnet, IssueStoreError> {
let (fm, body) = split_frontmatter(text)?;
let map = parse_frontmatter_map(fm)?;
let raw_ref: String = parse_json_field(&map, "issueRef")?;
let issue_ref =
IssueRef::from_str(&raw_ref).map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
let updated_at = parse_plain_u64(&map, "updatedAt")?;
Ok(IssueCarnet {
issue_ref,
carnet: MarkdownDoc::new(body),
version,
updated_by,
updated_at,
})
}
fn split_frontmatter(text: &str) -> Result<(&str, &str), IssueStoreError> {
let rest = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
.ok_or_else(|| IssueStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
let mut offset = 0;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches(['\r', '\n']) == "---" {
let body_start = offset + line.len();
return Ok((&rest[..offset], &rest[body_start..]));
}
offset += line.len();
}
Err(IssueStoreError::Invalid(
"missing closing frontmatter fence".to_owned(),
))
}
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, IssueStoreError> {
let mut map = BTreeMap::new();
for line in block.lines().filter(|line| !line.trim().is_empty()) {
let (key, value) = line
.split_once(':')
.ok_or_else(|| IssueStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
map.insert(key.trim().to_owned(), value.trim().to_owned());
}
Ok(map)
}
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, IssueStoreError> {
map.get(key)
.ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
.parse::<u64>()
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn parse_json_field<T: serde::de::DeserializeOwned>(
map: &BTreeMap<String, String>,
key: &str,
) -> Result<T, IssueStoreError> {
let value = map
.get(key)
.ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn json_value<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("serializing issue string cannot fail")
}