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

@ -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")
}

View File

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

View File

@ -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};

View File

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

View 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
}),
},
]
}

View File

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

View File

@ -0,0 +1,163 @@
use std::path::PathBuf;
use std::str::FromStr;
use domain::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
MarkdownDoc, ProjectPath,
};
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-issues-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn root(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
}
fn child(&self, rel: &str) -> PathBuf {
self.0.join(rel)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn issue(root: &ProjectPath, number: u64, title: &str) -> Issue {
let _ = root;
Issue::new(
IssueId::new_random(),
domain::IssueNumber::new(number).unwrap(),
title,
MarkdownDoc::new("Initial description"),
IssueStatus::Open,
IssuePriority::High,
MarkdownDoc::new("Initial carnet"),
Vec::new(),
vec![AgentIssueRef {
agent_id: domain::AgentId::new_random(),
role: AgentIssueRole::Assigned,
}],
IssueActor::User,
1_000,
)
.unwrap()
}
#[tokio::test]
async fn issue_store_writes_markdown_docs_and_index() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let issue = issue(&root, 1, "Wire issues");
store.create(&root, &issue).await.unwrap();
let issue_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/issue.md")).unwrap();
let carnet_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/carnet.md")).unwrap();
let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap();
assert!(issue_md.starts_with("---\n"));
assert!(issue_md.contains("title: \"Wire issues\""));
assert!(issue_md.ends_with("Initial description"));
assert!(carnet_md.contains("issueRef: \"#1\""));
assert!(carnet_md.ends_with("Initial carnet"));
assert!(index.contains("\"issueRef\": \"#1\""));
let loaded = store
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
.await
.unwrap();
assert_eq!(loaded.title, "Wire issues");
assert_eq!(loaded.carnet.as_str(), "Initial carnet");
}
#[tokio::test]
async fn issue_store_update_checks_expected_version() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let original = issue(&root, 2, "Conflict");
store.create(&root, &original).await.unwrap();
let updated = original
.clone()
.mutate(IssueActor::System, 2_000, |i| {
i.status = IssueStatus::InProgress;
})
.unwrap();
let err = store
.update(&root, &updated, updated.version)
.await
.unwrap_err();
assert!(matches!(err, IssueStoreError::VersionConflict { .. }));
store
.update(&root, &updated, original.version)
.await
.unwrap();
let loaded = store.get_by_ref(&root, updated.reference()).await.unwrap();
assert_eq!(loaded.status, IssueStatus::InProgress);
assert_eq!(loaded.version.get(), 2);
}
#[tokio::test]
async fn issue_store_lists_by_index_filters() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
store
.create(&root, &issue(&root, 1, "Alpha"))
.await
.unwrap();
let closed = issue(&root, 2, "Beta")
.mutate(IssueActor::System, 2_000, |i| {
i.status = IssueStatus::Closed;
i.priority = IssuePriority::Low;
})
.unwrap();
store.create(&root, &closed).await.unwrap();
let rows = store
.list(
&root,
IssueListFilter {
status: Some(IssueStatus::Closed),
priority: Some(IssuePriority::Low),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Beta");
}
#[tokio::test]
async fn allocator_never_reuses_numbers() {
let tmp = TempDir::new();
let root = tmp.root();
let allocator = FsIssueNumberAllocator::new();
let first = allocator.allocate_next(&root).await.unwrap();
let second = allocator.allocate_next(&root).await.unwrap();
let third = FsIssueNumberAllocator::new()
.allocate_next(&root)
.await
.unwrap();
assert_eq!(first.get(), 1);
assert_eq!(second.get(), 2);
assert_eq!(third.get(), 3);
}

View File

@ -500,6 +500,17 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
// Live-state tools (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"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",
] {
assert!(
names.contains(&expected),
@ -509,8 +520,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
13,
"exactly the thirteen exposed idea_* tools; got {names:?}"
23,
"exactly the twenty-three exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.