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:
576
crates/infrastructure/src/issues.rs
Normal file
576
crates/infrastructure/src/issues.rs
Normal 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")
|
||||
}
|
||||
@ -23,6 +23,7 @@ pub mod git;
|
||||
pub mod id;
|
||||
pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod mailbox;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
@ -57,8 +58,11 @@ pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use inspector::{
|
||||
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
||||
};
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
|
||||
pub mod jsonrpc;
|
||||
pub mod server;
|
||||
pub mod tickets;
|
||||
pub mod tools;
|
||||
pub mod transport;
|
||||
|
||||
@ -37,5 +38,6 @@ pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use server::McpServer;
|
||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
pub use transport::{MemoryTransport, StdioTransport};
|
||||
|
||||
@ -28,6 +28,7 @@ use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::tickets::TicketToolProvider;
|
||||
use super::tools::{self, ToolMapError};
|
||||
|
||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||
@ -61,6 +62,9 @@ pub struct McpServer {
|
||||
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
||||
/// composition root parse le `requester`. `None` ⇒ no-op.
|
||||
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||
/// provider maps those calls to application/domain `Issue` use cases.
|
||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -75,6 +79,7 @@ impl McpServer {
|
||||
events: None,
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ticket_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,6 +104,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the public ticket tool provider.
|
||||
#[must_use]
|
||||
pub fn with_ticket_tools(mut self, ticket_tools: Arc<dyn TicketToolProvider>) -> Self {
|
||||
self.ticket_tools = Some(ticket_tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a per-connection clone of this server tagged with the connected
|
||||
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||
///
|
||||
@ -115,6 +127,7 @@ impl McpServer {
|
||||
events: self.events.clone(),
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,6 +346,44 @@ impl McpServer {
|
||||
task_len={task_len}",
|
||||
);
|
||||
|
||||
if tools::is_ticket_tool(&name) {
|
||||
let result = match &self.ticket_tools {
|
||||
Some(provider) => {
|
||||
provider
|
||||
.handle_ticket_tool(&self.project, &self.requester, &name, arguments)
|
||||
.await
|
||||
}
|
||||
None => Err(super::tickets::TicketToolError::new(
|
||||
"notConfigured",
|
||||
"ticket tools are not configured",
|
||||
)),
|
||||
};
|
||||
self.publish_processed(&name, result.is_ok());
|
||||
return match result {
|
||||
Ok(value) => {
|
||||
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
Err(err) => {
|
||||
let text =
|
||||
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, true))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
|
||||
238
crates/infrastructure/src/orchestrator/mcp/tickets.rs
Normal file
238
crates/infrastructure/src/orchestrator/mcp/tickets.rs
Normal file
@ -0,0 +1,238 @@
|
||||
//! Public MCP ticket tools.
|
||||
//!
|
||||
//! The public surface says `ticket`, while the provider behind this trait maps
|
||||
//! to the application/domain `Issue` use cases.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::Project;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::tools::ToolDef;
|
||||
|
||||
/// Error returned by an MCP ticket tool provider.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{code}: {message}")]
|
||||
pub struct TicketToolError {
|
||||
/// Stable machine-readable code.
|
||||
pub code: &'static str,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl TicketToolError {
|
||||
/// Builds a typed ticket-tool error.
|
||||
#[must_use]
|
||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the error as a camelCase JSON object.
|
||||
#[must_use]
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({ "code": self.code, "message": self.message })
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider injected by the composition root to execute public ticket tools.
|
||||
#[async_trait]
|
||||
pub trait TicketToolProvider: Send + Sync {
|
||||
/// Executes one public `idea_ticket_*` tool for `project`.
|
||||
async fn handle_ticket_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, TicketToolError>;
|
||||
}
|
||||
|
||||
/// Returns true when `name` is one of the public ticket tools.
|
||||
#[must_use]
|
||||
pub fn is_ticket_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"idea_ticket_create"
|
||||
| "idea_ticket_read"
|
||||
| "idea_ticket_list"
|
||||
| "idea_ticket_update"
|
||||
| "idea_ticket_update_status"
|
||||
| "idea_ticket_update_priority"
|
||||
| "idea_ticket_read_carnet"
|
||||
| "idea_ticket_update_carnet"
|
||||
| "idea_ticket_link"
|
||||
| "idea_ticket_unlink"
|
||||
)
|
||||
}
|
||||
|
||||
/// Public ticket tool definitions advertised by `tools/list`.
|
||||
#[must_use]
|
||||
pub fn catalogue() -> Vec<ToolDef> {
|
||||
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
||||
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
||||
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
|
||||
let link_kind = json!({
|
||||
"type": "string",
|
||||
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
|
||||
});
|
||||
let link = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"targetRef": ticket_ref.clone(),
|
||||
"kind": link_kind.clone()
|
||||
},
|
||||
"required": ["targetRef", "kind"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
vec![
|
||||
ToolDef {
|
||||
name: "idea_ticket_create",
|
||||
description: "Create an IdeA ticket in the current project and return it as JSON.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"priority": priority.clone(),
|
||||
"status": status.clone(),
|
||||
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
|
||||
"links": { "type": "array", "items": link.clone() }
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_read",
|
||||
description: "Read one IdeA ticket by public reference (#N).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"includeCarnet": { "type": "boolean" }
|
||||
},
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_list",
|
||||
description: "List IdeA tickets in the current project.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": status.clone(),
|
||||
"priority": priority.clone(),
|
||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||
"text": { "type": "string" },
|
||||
"limit": { "type": "integer", "minimum": 1 },
|
||||
"cursor": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_update",
|
||||
description: "Update ticket fields using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"status": status.clone(),
|
||||
"priority": priority.clone(),
|
||||
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_update_status",
|
||||
description: "Update a ticket status using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"status": status.clone(),
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "status", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_update_priority",
|
||||
description: "Update a ticket priority using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"priority": priority.clone(),
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "priority", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_read_carnet",
|
||||
description: "Read the editable carnet attached to a ticket.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "ref": ticket_ref.clone() },
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_update_carnet",
|
||||
description: "Update a ticket carnet using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"carnet": { "type": "string" },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "carnet", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_link",
|
||||
description: "Add a link from one ticket to another using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"targetRef": ticket_ref.clone(),
|
||||
"kind": link_kind.clone(),
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "targetRef", "kind", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_unlink",
|
||||
description: "Remove a link from one ticket to another using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"targetRef": ticket_ref,
|
||||
"kind": link_kind,
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "targetRef", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -61,7 +61,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
| "idea_memory_read"
|
||||
| "idea_skill_read"
|
||||
| "idea_workstate_read"
|
||||
)
|
||||
) || is_ticket_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public ticket MCP tool.
|
||||
#[must_use]
|
||||
pub fn is_ticket_tool(tool: &str) -> bool {
|
||||
super::tickets::is_ticket_tool(tool)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
@ -70,7 +76,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
/// [`OrchestratorCommand`].
|
||||
#[must_use]
|
||||
pub fn catalogue() -> Vec<ToolDef> {
|
||||
vec![
|
||||
let mut tools = vec![
|
||||
ToolDef {
|
||||
name: "idea_list_agents",
|
||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
||||
@ -255,7 +261,9 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
]
|
||||
];
|
||||
tools.extend(super::tickets::catalogue());
|
||||
tools
|
||||
}
|
||||
|
||||
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
|
||||
|
||||
Reference in New Issue
Block a user