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

@ -285,6 +285,9 @@ pub fn run() {
tickets::close_ticket_chat,
tickets::ticket_list,
tickets::ticket_update,
tickets::ticket_attachment_add,
tickets::ticket_attachment_read,
tickets::ticket_attachment_mark_summarized,
tickets::ticket_read_carnet,
tickets::ticket_update_carnet,
tickets::ticket_link,

View File

@ -336,6 +336,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

@ -71,6 +71,9 @@ impl AppState {
bulk_delete: Arc::clone(&core.bulk_delete_issues),
read_carnet: Arc::clone(&core.read_issue_carnet),
update_carnet: Arc::clone(&core.update_issue_carnet),
add_attachment: Arc::clone(&core.add_issue_attachment),
read_attachment: Arc::clone(&core.read_issue_attachment),
mark_attachment_summarized: Arc::clone(&core.mark_issue_attachment_summarized),
link: Arc::clone(&core.link_issues),
unlink: Arc::clone(&core.unlink_issues),
list_sprints: Arc::clone(&core.list_sprints),

View File

@ -11,9 +11,10 @@ use serde_json::{json, Value};
use tauri::State;
use application::{
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput,
CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput,
LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput,
OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput,
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
@ -34,6 +35,9 @@ pub struct AppTicketToolProvider {
pub bulk_delete: Arc<application::BulkDeleteIssues>,
pub read_carnet: Arc<application::ReadIssueCarnet>,
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub add_attachment: Arc<application::AddIssueAttachment>,
pub read_attachment: Arc<application::ReadIssueAttachment>,
pub mark_attachment_summarized: Arc<application::MarkIssueAttachmentSummarized>,
pub link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>,
@ -251,6 +255,57 @@ impl TicketToolProvider for AppTicketToolProvider {
Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?)
))
}
"idea_ticket_attachment_add" => {
let req = mcp_attachment_add_request(project, arguments)?;
let issue = self
.add_attachment
.execute(AddIssueAttachmentInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
path: req.path,
mime: req.mime,
expected_version: domain::IssueVersion::new(req.expected_version)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
actor,
})
.await
.map_err(ticket_error)?
.issue;
json!(TicketDto::from_issue(issue, None))
}
"idea_ticket_attachment_read" => {
let req = mcp_attachment_read_request(project, arguments)?;
let content = self
.read_attachment
.execute(ReadIssueAttachmentInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
attachment_id: parse_attachment_id_dto(&req.attachment_id)
.map_err(dto_tool_error)?,
})
.await
.map_err(ticket_error)?
.content;
json!(TicketAttachmentContentDto::from(content))
}
"idea_ticket_attachment_mark_summarized" => {
let req = mcp_attachment_mark_summarized_request(project, arguments)?;
let issue = self
.mark_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
attachment_id: parse_attachment_id_dto(&req.attachment_id)
.map_err(dto_tool_error)?,
expected_version: domain::IssueVersion::new(req.expected_version)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
actor,
})
.await
.map_err(ticket_error)?
.issue;
json!(TicketDto::from_issue(issue, None))
}
"idea_ticket_link" => {
let issue = self
.link
@ -348,6 +403,78 @@ pub async fn ticket_read(
Ok(TicketDto::from_issue(issue, carnet))
}
#[tauri::command]
pub async fn ticket_attachment_add(
request: TicketAttachmentAddRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.add_issue_attachment
.execute(AddIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
path: request.path,
mime: request.mime,
expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
}
})?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_attachment_read(
request: TicketAttachmentReadRequestDto,
state: State<'_, AppState>,
) -> Result<TicketAttachmentContentDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let content = state
.read_issue_attachment
.execute(ReadIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
})
.await
.map_err(ErrorDto::from)?
.content;
Ok(TicketAttachmentContentDto::from(content))
}
#[tauri::command]
pub async fn ticket_attachment_mark_summarized(
request: TicketAttachmentMarkSummarizedRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.mark_issue_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
}
})?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_delete(
request: TicketDeleteRequestDto,
@ -876,6 +1003,36 @@ fn mcp_bulk_delete_request(
Ok(req)
}
fn mcp_attachment_add_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentAddRequestDto, TicketToolError> {
let mut req: TicketAttachmentAddRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn mcp_attachment_read_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentReadRequestDto, TicketToolError> {
let mut req: TicketAttachmentReadRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn mcp_attachment_mark_summarized_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentMarkSummarizedRequestDto, TicketToolError> {
let mut req: TicketAttachmentMarkSummarizedRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
}

View File

@ -3,17 +3,22 @@
//! The domain/code term is `Issue` throughout this layer.
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
use domain::{
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId,
IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator,
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project,
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind,
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
IssueVersion, LocalPath, MarkdownDoc, Project,
};
use crate::error::AppError;
/// Maximum raw attachment size for the minimal ticket attachment lot.
pub const TICKET_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
/// Input for [`CreateIssue::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateIssueInput {
@ -720,6 +725,199 @@ impl UpdateIssueCarnet {
}
}
/// Input for [`AddIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddIssueAttachmentInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Local source file path.
pub path: String,
/// Optional MIME type supplied by the driving adapter.
pub mime: Option<String>,
/// Expected optimistic version.
pub expected_version: IssueVersion,
/// Actor adding the attachment.
pub actor: IssueActor,
}
/// Output of [`AddIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddIssueAttachmentOutput {
/// Updated issue.
pub issue: Issue,
}
/// Adds a local file as a ticket attachment.
pub struct AddIssueAttachment {
issues: Arc<dyn IssueStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl AddIssueAttachment {
/// Builds the use case.
#[must_use]
pub fn new(
issues: Arc<dyn IssueStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
ids,
clock,
events,
}
}
/// Executes attachment add.
pub async fn execute(
&self,
input: AddIssueAttachmentInput,
) -> Result<AddIssueAttachmentOutput, AppError> {
let filename = filename_from_path(&input.path)?;
validate_attachment_filename(&filename)?;
let mime = sanitize_mime(input.mime.as_deref(), &filename)?;
let attachment_id = IssueAttachmentId::new(self.ids.new_uuid().to_string())
.map_err(|err| AppError::Invalid(err.to_string()))?;
let issue = self
.issues
.add_attachment_from_path(
&input.project.root,
input.issue_ref,
&LocalPath::new(input.path),
attachment_id,
filename,
mime,
input.actor,
now(&self.clock),
input.expected_version,
)
.await?;
self.events.publish(DomainEvent::IssueUpdated {
issue_ref: input.issue_ref,
version: issue.version,
});
Ok(AddIssueAttachmentOutput { issue })
}
}
/// Input for [`ReadIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueAttachmentInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Attachment id.
pub attachment_id: IssueAttachmentId,
}
/// Output of [`ReadIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueAttachmentOutput {
/// Attachment content.
pub content: IssueAttachmentContent,
}
/// Reads a ticket attachment.
pub struct ReadIssueAttachment {
issues: Arc<dyn IssueStore>,
}
impl ReadIssueAttachment {
/// Builds the use case.
#[must_use]
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
Self { issues }
}
/// Executes attachment read.
pub async fn execute(
&self,
input: ReadIssueAttachmentInput,
) -> Result<ReadIssueAttachmentOutput, AppError> {
Ok(ReadIssueAttachmentOutput {
content: self
.issues
.read_attachment(&input.project.root, input.issue_ref, &input.attachment_id)
.await?,
})
}
}
/// Input for [`MarkIssueAttachmentSummarized::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkIssueAttachmentSummarizedInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Attachment id.
pub attachment_id: IssueAttachmentId,
/// Expected optimistic version.
pub expected_version: IssueVersion,
/// Actor marking summary state.
pub actor: IssueActor,
}
/// Output of [`MarkIssueAttachmentSummarized::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkIssueAttachmentSummarizedOutput {
/// Updated issue.
pub issue: Issue,
}
/// Marks an attachment as summarized in the ticket carnet.
pub struct MarkIssueAttachmentSummarized {
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl MarkIssueAttachmentSummarized {
/// Builds the use case.
#[must_use]
pub fn new(
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
clock,
events,
}
}
/// Executes summary mark.
pub async fn execute(
&self,
input: MarkIssueAttachmentSummarizedInput,
) -> Result<MarkIssueAttachmentSummarizedOutput, AppError> {
let issue = self
.issues
.mark_attachment_summarized(
&input.project.root,
input.issue_ref,
&input.attachment_id,
input.actor,
now(&self.clock),
input.expected_version,
)
.await?;
self.events.publish(DomainEvent::IssueUpdated {
issue_ref: input.issue_ref,
version: issue.version,
});
Ok(MarkIssueAttachmentSummarizedOutput { issue })
}
}
/// Input for [`LinkIssues::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkIssuesInput {
@ -1030,6 +1228,75 @@ fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec<AgentId>) {
}
}
fn filename_from_path(path: &str) -> Result<String, AppError> {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
.ok_or_else(|| AppError::Invalid("attachment path has no filename".to_owned()))
}
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
let lowered = filename.to_ascii_lowercase();
let blocked = [
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
"deb", "rpm",
];
if lowered.trim().is_empty()
|| lowered.contains('/')
|| lowered.contains('\\')
|| lowered == "."
|| lowered == ".."
{
return Err(AppError::Invalid("invalid attachment filename".to_owned()));
}
if lowered
.rsplit_once('.')
.is_some_and(|(_, ext)| blocked.contains(&ext))
{
return Err(AppError::Invalid(
"executable attachments are not allowed".to_owned(),
));
}
Ok(())
}
fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result<String, AppError> {
let mime = raw
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| infer_mime(filename).to_owned());
if mime.eq_ignore_ascii_case("application/x-msdownload")
|| mime.eq_ignore_ascii_case("application/x-sh")
|| mime.eq_ignore_ascii_case("application/x-executable")
{
return Err(AppError::Invalid(
"executable attachments are not allowed".to_owned(),
));
}
Ok(mime)
}
fn infer_mime(filename: &str) -> &'static str {
match filename
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.as_deref()
{
Some("txt" | "md" | "log") => "text/plain",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("pdf") => "application/pdf",
Some("csv") => "text/csv",
_ => "application/octet-stream",
}
}
fn publish_update_events(
events: &Arc<dyn EventBus>,
issue: &Issue,

View File

@ -96,14 +96,18 @@ pub use git::{
};
pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use issues::{
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult,
BulkDeleteIssues, BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
AddIssueAttachment, AddIssueAttachmentInput, AddIssueAttachmentOutput, AssignIssueAgent,
AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, BulkDeleteIssues,
BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
BulkUpdateIssuePriorityInput, BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue,
CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput,
LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput,
ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput,
ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet,
UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput,
MarkIssueAttachmentSummarized, MarkIssueAttachmentSummarizedInput,
MarkIssueAttachmentSummarizedOutput, ReadIssue, ReadIssueAttachment, ReadIssueAttachmentInput,
ReadIssueAttachmentOutput, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
UpdateIssueOutput, TICKET_ATTACHMENT_MAX_BYTES,
};
pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,

View File

@ -139,6 +139,8 @@ impl OpenTicketAssistant {
"idea_ticket_update_priority".to_owned(),
"idea_ticket_read_carnet".to_owned(),
"idea_ticket_update_carnet".to_owned(),
"idea_ticket_attachment_read".to_owned(),
"idea_ticket_attachment_mark_summarized".to_owned(),
"idea_ticket_link".to_owned(),
"idea_ticket_unlink".to_owned(),
],

View File

@ -8,9 +8,10 @@ use domain::ports::{
IssueStoreError, StoreError,
};
use domain::{
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry,
IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef,
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority,
IssueRef, IssueStatus, IssueVersion, LocalPath, ManifestEntry, MarkdownDoc, ProfileId, Project,
ProjectId, ProjectPath, RemoteRef,
};
use uuid::Uuid;
@ -139,6 +140,42 @@ impl IssueStore for FakeIssues {
self.update(_root, &updated, expected_version).await?;
self.read_carnet(_root, issue_ref).await
}
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> {
unimplemented!("not needed")
}
async fn read_attachment(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError> {
unimplemented!("not needed")
}
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> {
unimplemented!("not needed")
}
}
struct SeqAllocator(Mutex<u64>);

View File

@ -4,10 +4,11 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
use domain::{
DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter,
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project,
ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder,
SprintStatus, SprintStore, SprintStoreError, SprintVersion,
DomainEvent, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
IssueId, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
IssueVersion, LocalPath, MarkdownDoc, Project, ProjectId, ProjectPath, RemoteRef, Sprint,
SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError,
SprintVersion,
};
use uuid::Uuid;
@ -198,6 +199,42 @@ impl IssueStore for FakeIssues {
) -> Result<IssueCarnet, IssueStoreError> {
unimplemented!("not needed")
}
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> {
unimplemented!("not needed")
}
async fn read_attachment(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError> {
unimplemented!("not needed")
}
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> {
unimplemented!("not needed")
}
}
struct SeqIds(Mutex<u128>);

View File

@ -17,11 +17,11 @@ use domain::profile::StructuredAdapter;
use domain::{
AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore,
AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus,
EventStream, Issue, IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber,
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc,
NetworkPolicy, PermissionSet, PreparedContext, ProfileId, ProfileStore, Project, ProjectId,
ProjectPath, ProjectPermissions, ProjectSystemPermissions, RemoteRef, SessionId,
SystemPermissionSet,
EventStream, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueStore,
IssueStoreError, IssueVersion, LocalPath, MarkdownDoc, NetworkPolicy, PermissionSet,
PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath, ProjectPermissions,
ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet,
};
use uuid::Uuid;
@ -138,6 +138,42 @@ impl IssueStore for FakeIssues {
) -> Result<IssueCarnet, IssueStoreError> {
unimplemented!()
}
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> {
unimplemented!()
}
async fn read_attachment(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError> {
unimplemented!()
}
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> {
unimplemented!()
}
}
struct FakeProfiles {

View File

@ -12,15 +12,15 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask,
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
@ -31,12 +31,13 @@ use application::{
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
@ -997,6 +998,12 @@ pub struct BackendCore {
pub read_issue_carnet: Arc<ReadIssueCarnet>,
/// Update a ticket carnet.
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
/// Attach a local file to a ticket.
pub add_issue_attachment: Arc<AddIssueAttachment>,
/// Read one ticket attachment.
pub read_issue_attachment: Arc<ReadIssueAttachment>,
/// Mark a ticket attachment as summarized in the carnet.
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
/// Link two public tickets.
pub link_issues: Arc<LinkIssues>,
/// Unlink public tickets.
@ -1696,6 +1703,19 @@ impl BackendCore {
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let add_issue_attachment = Arc::new(AddIssueAttachment::new(
Arc::clone(&issue_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let read_issue_attachment =
Arc::new(ReadIssueAttachment::new(Arc::clone(&issue_store_port)));
let mark_issue_attachment_summarized = Arc::new(MarkIssueAttachmentSummarized::new(
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let link_issues = Arc::new(LinkIssues::new(
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
@ -2843,6 +2863,9 @@ impl BackendCore {
bulk_delete_issues,
read_issue_carnet,
update_issue_carnet,
add_issue_attachment,
read_issue_attachment,
mark_issue_attachment_summarized,
link_issues,
unlink_issues,
assign_issue_agent,

View File

@ -349,6 +349,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

@ -10,9 +10,10 @@ use application::{
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
Project, SprintId, SprintStatus, SprintVersion,
AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
SprintVersion,
};
use infrastructure::TicketToolError;
@ -36,6 +37,7 @@ pub struct TicketDto {
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
pub assigned_agent_ids: Vec<String>,
pub attachments: Vec<TicketAttachmentDto>,
pub created_by: TicketActorDto,
pub updated_by: TicketActorDto,
pub created_at: u64,
@ -69,6 +71,31 @@ pub struct TicketListDto {
pub next_cursor: Option<String>,
}
/// Public attachment metadata DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentDto {
pub id: String,
pub filename: String,
pub mime: String,
pub size_bytes: u64,
pub added_by: TicketActorDto,
pub added_at: u64,
pub summarized_in_carnet: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_by: Option<TicketActorDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_at: Option<u64>,
}
/// Raw attachment content DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentContentDto {
pub attachment: TicketAttachmentDto,
pub content_base64: String,
}
/// Sprint context embedded in ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto {
pub r#ref: String,
}
/// Add attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentAddRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub path: String,
pub mime: Option<String>,
pub expected_version: u64,
}
/// Read attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentReadRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
}
/// Mark attachment summarized request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentMarkSummarizedRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
pub expected_version: u64,
}
/// List request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -437,6 +497,11 @@ impl TicketDto {
.filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id.to_string())
.collect(),
attachments: issue
.attachments
.into_iter()
.map(TicketAttachmentDto::from)
.collect(),
created_by: TicketActorDto::from(issue.created_by),
updated_by: TicketActorDto::from(issue.updated_by),
created_at: issue.created_at,
@ -547,6 +612,31 @@ impl From<IssueActor> for TicketActorDto {
}
}
impl From<IssueAttachment> for TicketAttachmentDto {
fn from(attachment: IssueAttachment) -> Self {
Self {
id: attachment.id.as_str().to_owned(),
filename: attachment.filename,
mime: attachment.mime,
size_bytes: attachment.size_bytes,
added_by: TicketActorDto::from(attachment.added_by),
added_at: attachment.added_at,
summarized_in_carnet: attachment.summarized_in_carnet,
summarized_by: attachment.summarized_by.map(TicketActorDto::from),
summarized_at: attachment.summarized_at,
}
}
}
impl From<IssueAttachmentContent> for TicketAttachmentContentDto {
fn from(content: IssueAttachmentContent) -> Self {
Self {
attachment: TicketAttachmentDto::from(content.attachment),
content_base64: URL_SAFE_NO_PAD.encode(content.bytes),
}
}
}
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
type Error = ErrorDto;
@ -560,6 +650,13 @@ impl TryFrom<TicketCreatorFilterDto> for IssueActor {
}
}
pub fn parse_attachment_id_dto(raw: &str) -> Result<IssueAttachmentId, ErrorDto> {
IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: err.to_string(),
})
}
impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self {
Self {

View File

@ -63,6 +63,8 @@ fn is_ticket_mutation_tool(tool: &str) -> bool {
| "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_ticket_create"

View File

@ -11,6 +11,35 @@ use thiserror::Error;
use crate::ids::{AgentId, IssueId, SprintId};
use crate::markdown::MarkdownDoc;
/// Stable ticket attachment identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IssueAttachmentId(String);
impl IssueAttachmentId {
/// Builds an attachment id.
///
/// # Errors
/// [`IssueError::InvalidAttachmentId`] when the id is empty or not filename-safe.
pub fn new(value: impl Into<String>) -> Result<Self, IssueError> {
let value = value.into();
if value.is_empty()
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(IssueError::InvalidAttachmentId(value));
}
Ok(Self(value))
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Sequential per-project issue number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
@ -239,6 +268,35 @@ pub enum IssueActor {
System,
}
/// Metadata for a file attached to a ticket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueAttachment {
/// Stable attachment id.
pub id: IssueAttachmentId,
/// Original display filename.
pub filename: String,
/// Stored relative path under the ticket directory.
pub path: String,
/// MIME type.
pub mime: String,
/// Size in bytes.
pub size_bytes: u64,
/// Actor that added the attachment.
pub added_by: IssueActor,
/// Add time, epoch milliseconds.
pub added_at: u64,
/// Whether an agent/user summarized the attachment into the ticket carnet.
#[serde(default)]
pub summarized_in_carnet: bool,
/// Actor that marked it summarized.
#[serde(default)]
pub summarized_by: Option<IssueActor>,
/// Summary mark time, epoch milliseconds.
#[serde(default)]
pub summarized_at: Option<u64>,
}
/// Issue aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -263,6 +321,9 @@ pub struct Issue {
pub links: Vec<IssueLink>,
/// Agent references.
pub agent_refs: Vec<AgentIssueRef>,
/// Attached files.
#[serde(default)]
pub attachments: Vec<IssueAttachment>,
/// Creator.
pub created_by: IssueActor,
/// Last updater.
@ -305,6 +366,7 @@ impl Issue {
carnet,
links,
agent_refs,
attachments: Vec::new(),
created_by: actor.clone(),
updated_by: actor,
created_at: now_ms,
@ -344,6 +406,34 @@ impl Issue {
if self.links.iter().any(|link| link.target == own) {
return Err(IssueError::SelfLink { reference: own });
}
let mut attachment_ids = std::collections::HashSet::new();
for attachment in &self.attachments {
if !attachment_ids.insert(attachment.id.clone()) {
return Err(IssueError::DuplicateAttachmentId(
attachment.id.as_str().to_owned(),
));
}
if attachment.filename.trim().is_empty()
|| attachment.filename.contains('/')
|| attachment.filename.contains('\\')
|| attachment.filename == "."
|| attachment.filename == ".."
{
return Err(IssueError::InvalidAttachmentFilename(
attachment.filename.clone(),
));
}
if attachment.path.starts_with('/')
|| attachment.path.starts_with('\\')
|| attachment.path.contains("..")
|| !attachment.path.starts_with("attachments/")
{
return Err(IssueError::InvalidAttachmentPath(attachment.path.clone()));
}
if attachment.mime.trim().is_empty() {
return Err(IssueError::InvalidAttachmentMime(attachment.mime.clone()));
}
}
Ok(())
}
@ -469,4 +559,19 @@ pub enum IssueError {
/// Self reference.
reference: IssueRef,
},
/// Attachment id is not flat filename-safe.
#[error("invalid attachment id: {0}")]
InvalidAttachmentId(String),
/// Attachment filename is empty or not flat.
#[error("invalid attachment filename: {0}")]
InvalidAttachmentFilename(String),
/// Attachment stored path is invalid.
#[error("invalid attachment path: {0}")]
InvalidAttachmentPath(String),
/// Attachment MIME type is invalid.
#[error("invalid attachment mime: {0}")]
InvalidAttachmentMime(String),
/// Attachment ids must be unique per issue.
#[error("duplicate attachment id: {0}")]
DuplicateAttachmentId(String),
}

View File

@ -127,9 +127,9 @@ pub use inbox::{
};
pub use issue::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
IssueVersion,
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentId,
IssueCarnet, IssueError, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
};
pub use sprint::{
@ -237,15 +237,15 @@ pub use ports::{
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, WindowStateStore,
IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError,
LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall,
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
SprintStoreError, StoreError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
};

View File

@ -40,7 +40,8 @@ use crate::ids::{
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
Issue, IssueAttachment, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter,
IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
@ -980,6 +981,15 @@ pub enum IssueStoreError {
Store(String),
}
/// Raw ticket attachment content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueAttachmentContent {
/// Attachment metadata.
pub attachment: IssueAttachment,
/// Raw bytes.
pub bytes: Vec<u8>,
}
/// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError {
@ -2360,6 +2370,40 @@ pub trait IssueStore: Send + Sync {
now_ms: u64,
expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError>;
/// Copies a local file into the ticket attachment folder and updates issue
/// metadata after checking the issue version.
async fn add_attachment_from_path(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
source: &LocalPath,
attachment_id: IssueAttachmentId,
filename: String,
mime: String,
actor: crate::issue::IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError>;
/// Reads an attachment's raw bytes.
async fn read_attachment(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError>;
/// Marks an attachment as summarized in the ticket carnet.
async fn mark_attachment_summarized(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
actor: crate::issue::IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError>;
}
/// Persistence port for project-scoped sprints.

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();

View File

@ -34,20 +34,21 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use application::{
AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput,
AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput,
CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput,
DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput,
ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision,
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput,
CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput,
DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput,
DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions,
MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision,
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput,
ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
@ -64,33 +65,36 @@ use domain::{
};
use backend::dto::{
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
parse_profile_id, parse_project_id, parse_ref_dto, parse_session_id, parse_skill_id,
parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id,
sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto,
AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto,
parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id,
parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto,
AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
@ -2478,6 +2482,11 @@ async fn invoke(
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
"ticket_update" => invoke_ticket_update(&request.args, &state.app).await,
"ticket_attachment_add" => invoke_ticket_attachment_add(&request.args, &state.app).await,
"ticket_attachment_read" => invoke_ticket_attachment_read(&request.args, &state.app).await,
"ticket_attachment_mark_summarized" => {
invoke_ticket_attachment_mark_summarized(&request.args, &state.app).await
}
"ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await,
"ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await,
"ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await,
@ -3050,6 +3059,72 @@ async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result<Value
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_add(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAttachmentAddRequestDto>("ticket_attachment_add", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.add_issue_attachment
.execute(AddIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
path: request.path,
mime: request.mime,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_read(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<TicketAttachmentReadRequestDto>("ticket_attachment_read", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let content = state
.read_issue_attachment
.execute(ReadIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
})
.await
.map_err(ErrorDto::from)?
.content;
serde_json::to_value(TicketAttachmentContentDto::from(content)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_mark_summarized(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAttachmentMarkSummarizedRequestDto>(
"ticket_attachment_mark_summarized",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.mark_issue_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;

View File

@ -29,6 +29,7 @@ import type {
Sprint,
TerminalSession,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway {
return unsupportedOnWeb("Native file picker");
}
pickFile(): Promise<string | null> {
// Desktop-only, same rationale as `pickFolder`.
return unsupportedOnWeb("Native file picker");
}
onAppExitWorkGuard(
_handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {
@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway {
request: { projectId, ref, carnet, expectedVersion },
});
}
addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
}
readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
return this.http.invoke<TicketAttachmentContent>("ticket_attachment_read", {
request: { projectId, ref, attachmentId },
});
}
markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
}
link(
projectId: string,
ref: string,

View File

@ -66,6 +66,7 @@ import type {
SystemPermissionSet,
TerminalSession,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -179,6 +180,22 @@ function sameTicketCreator(
return true;
}
function filenameFromPath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment";
}
function inferAttachmentMime(filename: string, supplied?: string | null): string {
if (supplied?.trim()) return supplied.trim();
const lower = filename.toLowerCase();
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".pdf")) return "application/pdf";
return "application/octet-stream";
}
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
return "/home/user/mock-plugin.ideaplug";
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFile(): Promise<string | null> {
return "/home/user/mock-attachment.txt";
}
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
/** Count of `confirmAppExit()` calls, for test assertions. */
confirmAppExitCallCount = 0;
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
private chatSessions = new Map<string, string>();
private chatCounter = 0;
private attachmentCounter = 0;
constructor(private readonly system?: MockSystemGateway) {}
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
_seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket);
if (stored.sprintId === undefined) stored.sprintId = null;
stored.attachments ??= [];
this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set(
projectId,
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
sprintId: null,
links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
attachments: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: now,
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(ticket);
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const filename = filenameFromPath(path);
this.attachmentCounter += 1;
ticket.attachments.push({
id: `att-${this.attachmentCounter}`,
filename,
mime: inferAttachmentMime(filename, mime),
sizeBytes: Math.max(1, filename.length * 128),
addedBy: { kind: "user" },
addedAt: Date.now(),
summarizedInCarnet: false,
summarizedBy: null,
summarizedAt: null,
});
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
const ticket = this.require(projectId, ref);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
return {
attachment: structuredClone(attachment),
contentBase64: btoa(`mock content for ${attachment.filename}`),
};
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
attachment.summarizedInCarnet = true;
attachment.summarizedBy = { kind: "user" };
attachment.summarizedAt = Date.now();
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async link(
projectId: string,
ref: string,

View File

@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
return typeof result === "string" ? result : null;
}
async pickFile(): Promise<string | null> {
const result = await open({ directory: false, multiple: false });
return typeof result === "string" ? result : null;
}
async onAppExitWorkGuard(
handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {

View File

@ -54,4 +54,47 @@ describe("TauriTicketGateway invoke payloads", () => {
},
});
});
it("wraps ticket attachment commands in the request DTO", async () => {
const gateway = new TauriTicketGateway();
await gateway.addAttachment(
"proj-1",
"#12",
"/tmp/note.txt",
3,
"text/plain",
);
await gateway.readAttachment("proj-1", "#12", "att-1");
await gateway.markAttachmentSummarized("proj-1", "#12", "att-1", 4);
expect(invoke).toHaveBeenNthCalledWith(1, "ticket_attachment_add", {
request: {
projectId: "proj-1",
ref: "#12",
path: "/tmp/note.txt",
expectedVersion: 3,
mime: "text/plain",
},
});
expect(invoke).toHaveBeenNthCalledWith(2, "ticket_attachment_read", {
request: {
projectId: "proj-1",
ref: "#12",
attachmentId: "att-1",
},
});
expect(invoke).toHaveBeenNthCalledWith(
3,
"ticket_attachment_mark_summarized",
{
request: {
projectId: "proj-1",
ref: "#12",
attachmentId: "att-1",
expectedVersion: 4,
},
},
);
});
});

View File

@ -17,6 +17,7 @@ import type {
ReplyChunk,
Sprint,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -134,6 +135,39 @@ export class TauriTicketGateway implements TicketGateway {
});
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
return invoke<TicketAttachmentContent>("ticket_attachment_read", {
request: { projectId, ref, attachmentId },
});
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
}
async link(
projectId: string,
ref: string,

View File

@ -1473,6 +1473,25 @@ export type TicketActor =
| { kind: "agent"; agentId: string }
| { kind: "system" };
/** Metadata for a file attached to a ticket (mirror of `TicketAttachmentDto`). */
export interface TicketAttachment {
id: string;
filename: string;
mime: string;
sizeBytes: number;
addedBy: TicketActor;
addedAt: number;
summarizedInCarnet: boolean;
summarizedBy?: TicketActor | null;
summarizedAt?: number | null;
}
/** Raw ticket attachment content (mirror of `TicketAttachmentContentDto`). */
export interface TicketAttachmentContent {
attachment: TicketAttachment;
contentBase64: string;
}
/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
export interface TicketLink {
targetRef: TicketRef;
@ -1510,6 +1529,7 @@ export interface Ticket {
carnet?: string;
links: TicketLink[];
assignedAgentIds: string[];
attachments: TicketAttachment[];
createdBy: TicketActor;
updatedBy: TicketActor;
createdAt: number;

View File

@ -76,6 +76,18 @@ function Section({
);
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function TicketDetail({
projectId,
ticketRef,
@ -389,6 +401,72 @@ export function TicketDetail({
</div>
</Section>
{/* ── Attachments (#108) ── */}
<Section title="Pièces jointes">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted">
{t.attachments.length === 0
? "Aucune pièce jointe."
: `${t.attachments.length} pièce${
t.attachments.length > 1 ? "s" : ""
} jointe${t.attachments.length > 1 ? "s" : ""}.`}
</p>
<Button
size="sm"
disabled={vm.busy}
loading={vm.busy}
onClick={() => void vm.attachFile()}
>
Joindre
</Button>
</div>
{t.attachments.length > 0 && (
<ul className="flex flex-col gap-2">
{t.attachments.map((attachment) => (
<li
key={attachment.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-raised/30 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{attachment.filename}
</p>
<p className="text-xs text-muted">
{formatBytes(attachment.sizeBytes)} ·{" "}
{attachment.mime || "application/octet-stream"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
attachment.summarizedInCarnet
? "bg-success/10 text-success"
: "bg-raised text-muted",
)}
>
Résumé : {attachment.summarizedInCarnet ? "oui" : "non"}
</span>
{!attachment.summarizedInCarnet && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() =>
void vm.markAttachmentSummarized(attachment.id)
}
aria-label={`marquer résumé ${attachment.filename}`}
>
Marquer résumé
</Button>
)}
</div>
</li>
))}
</ul>
)}
</Section>
{/* ── Links (F5) ── */}
<Section title="Linked tickets">
{t.links.length === 0 ? (

View File

@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
});
it("adds ticket attachments and marks them summarized (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "With attachment" });
const attached = await ticket.addAttachment(
PROJECT_ID,
t.ref,
"/tmp/spec.pdf",
t.version,
"application/pdf",
);
expect(attached.version).toBe(2);
expect(attached.attachments).toMatchObject([
{
filename: "spec.pdf",
mime: "application/pdf",
summarizedInCarnet: false,
},
]);
const content = await ticket.readAttachment(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
);
expect(content.attachment.filename).toBe("spec.pdf");
expect(content.contentBase64).toBeTruthy();
const summarized = await ticket.markAttachmentSummarized(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
attached.version,
);
expect(summarized.version).toBe(3);
expect(summarized.attachments[0]).toMatchObject({
summarizedInCarnet: true,
summarizedBy: { kind: "user" },
});
});
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
@ -515,6 +555,36 @@ describe("TicketsView", () => {
});
});
it("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Attachable"));
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
fireEvent.click(within(dialog).getByText("Joindre"));
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
const attached = await ticket.read(PROJECT_ID, t.ref);
expect(attached.attachments).toHaveLength(1);
fireEvent.click(
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
);
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
});
});
it("assigns only known project agents", async () => {
const known = await seedAgent(agent, "Backend");
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });

View File

@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
priority?: Ticket["priority"];
}) => Promise<boolean>;
saveCarnet: (carnet: string) => Promise<boolean>;
attachFile: () => Promise<boolean>;
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
@ -193,6 +195,26 @@ export function useTicketDetail(
[run, gateway, projectId, ref],
);
const attachFile: TicketDetailViewModel["attachFile"] = useCallback(async () => {
if (!system) return false;
try {
const path = await system.pickFile();
if (!path) return false;
return run((version) => gateway.addAttachment(projectId, ref, path, version));
} catch (e) {
setError(describe(e));
return false;
}
}, [system, run, gateway, projectId, ref]);
const markAttachmentSummarized: TicketDetailViewModel["markAttachmentSummarized"] = useCallback(
(attachmentId) =>
run((version) =>
gateway.markAttachmentSummarized(projectId, ref, attachmentId, version),
),
[run, gateway, projectId, ref],
);
const link: TicketDetailViewModel["link"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
@ -243,6 +265,8 @@ export function useTicketDetail(
refresh,
updateFields,
saveCarnet,
attachFile,
markAttachmentSummarized,
link,
unlink,
assign,

View File

@ -28,6 +28,7 @@ function ticket(over: Partial<Ticket> = {}): Ticket {
sprintId: null,
links: [],
assignedAgentIds: [],
attachments: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: 1,

View File

@ -68,6 +68,7 @@ import type {
TerminalSession,
Ticket,
TicketBulkResult,
TicketAttachmentContent,
TicketChat,
TicketCarnet,
TicketLinkKind,
@ -96,6 +97,12 @@ export interface SystemGateway {
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
*/
pickArchiveFile(): Promise<string | null>;
/**
* Opens a native file picker for one local file and returns its path, or
* `null` when cancelled. Used for ticket attachments; no file bytes cross the
* frontend boundary.
*/
pickFile(): Promise<string | null>;
/**
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
* closing the main window is intercepted because it would interrupt active
@ -1096,6 +1103,27 @@ export interface TicketGateway {
carnet: string,
expectedVersion: number,
): Promise<Ticket>;
/** Adds a local file path as a ticket attachment and returns the updated ticket. */
addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket>;
/** Reads raw attachment content as base64. Not used by the minimal v1 UI. */
readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent>;
/** Marks an attachment as summarized in the ticket carnet. */
markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket>;
/** Links this ticket to another `#id` with the given relationship kind. */
link(
projectId: string,