feat(tickets): ajoute les pièces jointes sur tickets (#108)

Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté
backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le
domaine (Issue, ports), l'application (usecases + assistant de ticket), les
adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web-
server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:08:45 +02:00
parent 2692b9cc03
commit 8158057b1d
34 changed files with 1722 additions and 102 deletions

View File

@ -14,20 +14,23 @@ 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, SprintId,
AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
};
const IDEAI_DIR: &str = ".ideai";
const ISSUES_DIR: &str = "tickets";
const ISSUE_FILE: &str = "issue.md";
const CARNET_FILE: &str = "carnet.md";
const ATTACHMENTS_DIR: &str = "attachments";
const INDEX_FILE: &str = "index.json";
const COUNTER_FILE: &str = "counter.json";
const COUNTER_LOCK: &str = "counter.lock";
const MUTATION_LOCK: &str = "mutation.lock";
const INDEX_VERSION: u32 = 1;
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
/// Filesystem-backed issue store.
#[derive(Debug, Clone, Default)]
@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
issue_dir(root, number).join(CARNET_FILE)
}
fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf {
issue_dir(root, number).join(relative)
}
fn index_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(INDEX_FILE)
}
@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError {
IssueStoreError::Store(err.to_string())
}
fn attachment_by_id(
issue: &Issue,
id: &IssueAttachmentId,
) -> Result<IssueAttachment, IssueStoreError> {
issue
.attachments
.iter()
.find(|attachment| &attachment.id == id)
.cloned()
.ok_or(IssueStoreError::NotFound)
}
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?;
@ -451,6 +470,158 @@ impl IssueStore for FsIssueStore {
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
async fn add_attachment_from_path(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
source: &LocalPath,
attachment_id: IssueAttachmentId,
filename: String,
mime: String,
actor: IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError> {
let source_path = PathBuf::from(source.as_str());
let source_meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
if !source_meta.is_file() {
return Err(IssueStoreError::Invalid(
"attachment source must be a file".to_owned(),
));
}
let size_bytes = source_meta.len();
if size_bytes > ATTACHMENT_MAX_BYTES {
return Err(IssueStoreError::Invalid(format!(
"attachment exceeds {} bytes",
ATTACHMENT_MAX_BYTES
)));
}
let stored_name = format!("{}-{}", attachment_id.as_str(), filename);
let attachment = IssueAttachment {
id: attachment_id,
filename,
path: format!("{ATTACHMENTS_DIR}/{stored_name}"),
mime,
size_bytes,
added_by: actor,
added_at: now_ms,
summarized_in_carnet: false,
summarized_by: None,
summarized_at: None,
};
let lock_path = mutation_lock_path(root);
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
let result = async {
let current = load_issue(root, issue_ref).await?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
if current
.attachments
.iter()
.any(|existing| existing.id == attachment.id)
{
return Err(IssueStoreError::Invalid(format!(
"attachment {} already exists",
attachment.id.as_str()
)));
}
let dest = attachment_path(root, issue_ref.number(), &attachment.path);
let parent = dest.parent().ok_or_else(|| {
IssueStoreError::Store("attachment path has no parent".to_owned())
})?;
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
tokio::fs::copy(&source_path, &dest)
.await
.map_err(io_error)?;
let updated = current
.mutate(attachment.added_by.clone(), attachment.added_at, |issue| {
issue.attachments.push(attachment);
})
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
save_issue(root, &updated).await?;
rebuild_index(root).await?;
Ok(updated)
}
.await;
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
async fn read_attachment(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError> {
let issue = load_issue(root, issue_ref).await?;
let attachment = attachment_by_id(&issue, attachment_id)?;
let bytes = tokio::fs::read(attachment_path(root, issue.number, &attachment.path))
.await
.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
IssueStoreError::NotFound
} else {
io_error(err)
}
})?;
Ok(IssueAttachmentContent { attachment, bytes })
}
async fn mark_attachment_summarized(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
actor: IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError> {
let lock_path = mutation_lock_path(root);
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
let result = async {
let current = load_issue(root, issue_ref).await?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
if !current
.attachments
.iter()
.any(|attachment| &attachment.id == attachment_id)
{
return Err(IssueStoreError::NotFound);
}
let updated = current
.mutate(actor.clone(), now_ms, |issue| {
if let Some(attachment) = issue
.attachments
.iter_mut()
.find(|attachment| &attachment.id == attachment_id)
{
attachment.summarized_in_carnet = true;
attachment.summarized_by = Some(actor);
attachment.summarized_at = Some(now_ms);
}
})
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
save_issue(root, &updated).await?;
rebuild_index(root).await?;
Ok(updated)
}
.await;
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
}
#[async_trait]
@ -509,6 +680,7 @@ priority: {}\n\
sprint: {}\n\
links: {}\n\
agentRefs: {}\n\
attachments: {}\n\
createdBy: {}\n\
updatedBy: {}\n\
createdAt: {}\n\
@ -523,6 +695,7 @@ version: {}\n\
json_value(&issue.sprint),
json_value(&issue.links),
json_value(&issue.agent_refs),
json_value(&issue.attachments),
json_value(&issue.created_by),
json_value(&issue.updated_by),
issue.created_at,
@ -563,6 +736,8 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
let attachments: Vec<IssueAttachment> =
parse_optional_json_field(&map, "attachments")?.unwrap_or_default();
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")?;
@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
carnet: MarkdownDoc::default(),
links,
agent_refs,
attachments,
created_by,
updated_by,
created_at,

View File

@ -911,6 +911,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
| "idea_ticket_update_status"
| "idea_ticket_update_priority"
| "idea_ticket_update_carnet"
| "idea_ticket_attachment_add"
| "idea_ticket_attachment_mark_summarized"
| "idea_ticket_link"
| "idea_ticket_unlink"
)

View File

@ -292,6 +292,48 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_attachment_add",
description: "Attach a local file to a ticket using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"path": { "type": "string" },
"mime": { "type": "string" },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "path", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_attachment_read",
description: "Read one ticket attachment as base64 content.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"attachmentId": { "type": "string" }
},
"required": ["ref", "attachmentId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_attachment_mark_summarized",
description: "Mark a ticket attachment as summarized in the carnet.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"attachmentId": { "type": "string" },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "attachmentId", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_link",
description: "Add a link from one ticket to another using optimistic concurrency.",

View File

@ -53,6 +53,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_ticket_attachment_read",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_update_carnet",
"idea_ticket_attachment_add",
"idea_ticket_attachment_mark_summarized",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",

View File

@ -2,9 +2,9 @@ use std::path::PathBuf;
use std::str::FromStr;
use domain::{
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
MarkdownDoc, ProjectPath, SprintId,
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId,
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId,
};
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid;
@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() {
assert_eq!(rows[0].sprint, Some(sprint_id));
}
#[tokio::test]
async fn issue_store_adds_reads_and_marks_attachment_summarized() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let issue = issue(&root, 7, "Attachment ticket");
store.create(&root, &issue).await.unwrap();
let source = tmp.child("source-note.txt");
std::fs::write(&source, "attachment payload").unwrap();
let attachment_id = IssueAttachmentId::new("att_1".to_owned()).unwrap();
let updated = store
.add_attachment_from_path(
&root,
issue.reference(),
&LocalPath::new(source.to_string_lossy().to_string()),
attachment_id.clone(),
"source-note.txt".to_owned(),
"text/plain".to_owned(),
IssueActor::User,
2_000,
issue.version,
)
.await
.unwrap();
assert_eq!(updated.attachments.len(), 1);
assert_eq!(updated.attachments[0].filename, "source-note.txt");
assert_eq!(updated.attachments[0].mime, "text/plain");
assert_eq!(updated.attachments[0].size_bytes, 18);
assert!(!updated.attachments[0].summarized_in_carnet);
assert!(tmp
.child(".ideai/tickets/7/attachments/att_1-source-note.txt")
.exists());
let content = store
.read_attachment(&root, issue.reference(), &attachment_id)
.await
.unwrap();
assert_eq!(content.attachment.id, attachment_id);
assert_eq!(content.bytes, b"attachment payload");
let summarized = store
.mark_attachment_summarized(
&root,
issue.reference(),
&attachment_id,
IssueActor::System,
3_000,
updated.version,
)
.await
.unwrap();
let attachment = &summarized.attachments[0];
assert!(attachment.summarized_in_carnet);
assert_eq!(attachment.summarized_by, Some(IssueActor::System));
assert_eq!(attachment.summarized_at, Some(3_000));
}
#[tokio::test]
async fn issue_store_delete_removes_files_and_index_entry() {
let tmp = TempDir::new();