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::close_ticket_chat,
tickets::ticket_list, tickets::ticket_list,
tickets::ticket_update, tickets::ticket_update,
tickets::ticket_attachment_add,
tickets::ticket_attachment_read,
tickets::ticket_attachment_mark_summarized,
tickets::ticket_read_carnet, tickets::ticket_read_carnet,
tickets::ticket_update_carnet, tickets::ticket_update_carnet,
tickets::ticket_link, 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_status"
| "idea_ticket_update_priority" | "idea_ticket_update_priority"
| "idea_ticket_update_carnet" | "idea_ticket_update_carnet"
| "idea_ticket_attachment_add"
| "idea_ticket_attachment_mark_summarized"
| "idea_ticket_link" | "idea_ticket_link"
| "idea_ticket_unlink" | "idea_ticket_unlink"
) )

View File

@ -71,6 +71,9 @@ impl AppState {
bulk_delete: Arc::clone(&core.bulk_delete_issues), bulk_delete: Arc::clone(&core.bulk_delete_issues),
read_carnet: Arc::clone(&core.read_issue_carnet), read_carnet: Arc::clone(&core.read_issue_carnet),
update_carnet: Arc::clone(&core.update_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), link: Arc::clone(&core.link_issues),
unlink: Arc::clone(&core.unlink_issues), unlink: Arc::clone(&core.unlink_issues),
list_sprints: Arc::clone(&core.list_sprints), list_sprints: Arc::clone(&core.list_sprints),

View File

@ -11,9 +11,10 @@ use serde_json::{json, Value};
use tauri::State; use tauri::State;
use application::{ use application::{
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput,
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput,
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput,
OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput,
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
}; };
@ -34,6 +35,9 @@ pub struct AppTicketToolProvider {
pub bulk_delete: Arc<application::BulkDeleteIssues>, pub bulk_delete: Arc<application::BulkDeleteIssues>,
pub read_carnet: Arc<application::ReadIssueCarnet>, pub read_carnet: Arc<application::ReadIssueCarnet>,
pub update_carnet: Arc<application::UpdateIssueCarnet>, 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 link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>, pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>, 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?) 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" => { "idea_ticket_link" => {
let issue = self let issue = self
.link .link
@ -348,6 +403,78 @@ pub async fn ticket_read(
Ok(TicketDto::from_issue(issue, carnet)) 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] #[tauri::command]
pub async fn ticket_delete( pub async fn ticket_delete(
request: TicketDeleteRequestDto, request: TicketDeleteRequestDto,
@ -876,6 +1003,36 @@ fn mcp_bulk_delete_request(
Ok(req) 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> { 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)) 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. //! The domain/code term is `Issue` throughout this layer.
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use domain::{AgentContextStore, Clock, EventBus, IdGenerator}; use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
use domain::{ use domain::{
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator, IssueAttachmentId, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind,
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project, IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
IssueVersion, LocalPath, MarkdownDoc, Project,
}; };
use crate::error::AppError; 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`]. /// Input for [`CreateIssue::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateIssueInput { 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`]. /// Input for [`LinkIssues::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkIssuesInput { 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( fn publish_update_events(
events: &Arc<dyn EventBus>, events: &Arc<dyn EventBus>,
issue: &Issue, issue: &Issue,

View File

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

View File

@ -139,6 +139,8 @@ impl OpenTicketAssistant {
"idea_ticket_update_priority".to_owned(), "idea_ticket_update_priority".to_owned(),
"idea_ticket_read_carnet".to_owned(), "idea_ticket_read_carnet".to_owned(),
"idea_ticket_update_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_link".to_owned(),
"idea_ticket_unlink".to_owned(), "idea_ticket_unlink".to_owned(),
], ],

View File

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

View File

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

View File

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

View File

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

View File

@ -10,9 +10,10 @@ use application::{
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput, BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
}; };
use domain::{ use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
Project, SprintId, SprintStatus, SprintVersion, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
SprintVersion,
}; };
use infrastructure::TicketToolError; use infrastructure::TicketToolError;
@ -36,6 +37,7 @@ pub struct TicketDto {
pub carnet: Option<String>, pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>, pub links: Vec<TicketLinkDto>,
pub assigned_agent_ids: Vec<String>, pub assigned_agent_ids: Vec<String>,
pub attachments: Vec<TicketAttachmentDto>,
pub created_by: TicketActorDto, pub created_by: TicketActorDto,
pub updated_by: TicketActorDto, pub updated_by: TicketActorDto,
pub created_at: u64, pub created_at: u64,
@ -69,6 +71,31 @@ pub struct TicketListDto {
pub next_cursor: Option<String>, 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. /// Sprint context embedded in ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto {
pub r#ref: String, 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. /// List request.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -437,6 +497,11 @@ impl TicketDto {
.filter(|r| r.role == AgentIssueRole::Assigned) .filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id.to_string()) .map(|r| r.agent_id.to_string())
.collect(), .collect(),
attachments: issue
.attachments
.into_iter()
.map(TicketAttachmentDto::from)
.collect(),
created_by: TicketActorDto::from(issue.created_by), created_by: TicketActorDto::from(issue.created_by),
updated_by: TicketActorDto::from(issue.updated_by), updated_by: TicketActorDto::from(issue.updated_by),
created_at: issue.created_at, 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 { impl TryFrom<TicketCreatorFilterDto> for IssueActor {
type Error = ErrorDto; 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 { impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self { fn from(carnet: IssueCarnet) -> Self {
Self { Self {

View File

@ -63,6 +63,8 @@ fn is_ticket_mutation_tool(tool: &str) -> bool {
| "idea_ticket_bulk_update_priority" | "idea_ticket_bulk_update_priority"
| "idea_ticket_bulk_delete" | "idea_ticket_bulk_delete"
| "idea_ticket_update_carnet" | "idea_ticket_update_carnet"
| "idea_ticket_attachment_add"
| "idea_ticket_attachment_mark_summarized"
| "idea_ticket_link" | "idea_ticket_link"
| "idea_ticket_unlink" | "idea_ticket_unlink"
| "idea_ticket_create" | "idea_ticket_create"

View File

@ -11,6 +11,35 @@ use thiserror::Error;
use crate::ids::{AgentId, IssueId, SprintId}; use crate::ids::{AgentId, IssueId, SprintId};
use crate::markdown::MarkdownDoc; 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. /// Sequential per-project issue number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
@ -239,6 +268,35 @@ pub enum IssueActor {
System, 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. /// Issue aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -263,6 +321,9 @@ pub struct Issue {
pub links: Vec<IssueLink>, pub links: Vec<IssueLink>,
/// Agent references. /// Agent references.
pub agent_refs: Vec<AgentIssueRef>, pub agent_refs: Vec<AgentIssueRef>,
/// Attached files.
#[serde(default)]
pub attachments: Vec<IssueAttachment>,
/// Creator. /// Creator.
pub created_by: IssueActor, pub created_by: IssueActor,
/// Last updater. /// Last updater.
@ -305,6 +366,7 @@ impl Issue {
carnet, carnet,
links, links,
agent_refs, agent_refs,
attachments: Vec::new(),
created_by: actor.clone(), created_by: actor.clone(),
updated_by: actor, updated_by: actor,
created_at: now_ms, created_at: now_ms,
@ -344,6 +406,34 @@ impl Issue {
if self.links.iter().any(|link| link.target == own) { if self.links.iter().any(|link| link.target == own) {
return Err(IssueError::SelfLink { reference: 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(()) Ok(())
} }
@ -469,4 +559,19 @@ pub enum IssueError {
/// Self reference. /// Self reference.
reference: IssueRef, 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::{ pub use issue::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentId,
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueCarnet, IssueError, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssueVersion, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
}; };
pub use sprint::{ pub use sprint::{
@ -237,15 +237,15 @@ pub use ports::{
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath, IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError,
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
TemplateStore, WindowStateStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
}; };

View File

@ -40,7 +40,8 @@ use crate::ids::{
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
}; };
use crate::issue::{ 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::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::ProjectMcpToolPermissions; use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
@ -980,6 +981,15 @@ pub enum IssueStoreError {
Store(String), 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. /// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError { pub enum SprintStoreError {
@ -2360,6 +2370,40 @@ pub trait IssueStore: Send + Sync {
now_ms: u64, now_ms: u64,
expected_version: IssueVersion, expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError>; ) -> 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. /// Persistence port for project-scoped sprints.

View File

@ -14,20 +14,23 @@ use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use domain::{ use domain::{
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
}; };
const IDEAI_DIR: &str = ".ideai"; const IDEAI_DIR: &str = ".ideai";
const ISSUES_DIR: &str = "tickets"; const ISSUES_DIR: &str = "tickets";
const ISSUE_FILE: &str = "issue.md"; const ISSUE_FILE: &str = "issue.md";
const CARNET_FILE: &str = "carnet.md"; const CARNET_FILE: &str = "carnet.md";
const ATTACHMENTS_DIR: &str = "attachments";
const INDEX_FILE: &str = "index.json"; const INDEX_FILE: &str = "index.json";
const COUNTER_FILE: &str = "counter.json"; const COUNTER_FILE: &str = "counter.json";
const COUNTER_LOCK: &str = "counter.lock"; const COUNTER_LOCK: &str = "counter.lock";
const MUTATION_LOCK: &str = "mutation.lock"; const MUTATION_LOCK: &str = "mutation.lock";
const INDEX_VERSION: u32 = 1; const INDEX_VERSION: u32 = 1;
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
/// Filesystem-backed issue store. /// Filesystem-backed issue store.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
issue_dir(root, number).join(CARNET_FILE) 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 { fn index_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(INDEX_FILE) issue_root(root).join(INDEX_FILE)
} }
@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError {
IssueStoreError::Store(err.to_string()) 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> { async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
let number = issue_ref.number(); let number = issue_ref.number();
let issue_text = read_string(&issue_path(root, number)).await?; 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; let _ = tokio::fs::remove_file(&lock_path).await;
result 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] #[async_trait]
@ -509,6 +680,7 @@ priority: {}\n\
sprint: {}\n\ sprint: {}\n\
links: {}\n\ links: {}\n\
agentRefs: {}\n\ agentRefs: {}\n\
attachments: {}\n\
createdBy: {}\n\ createdBy: {}\n\
updatedBy: {}\n\ updatedBy: {}\n\
createdAt: {}\n\ createdAt: {}\n\
@ -523,6 +695,7 @@ version: {}\n\
json_value(&issue.sprint), json_value(&issue.sprint),
json_value(&issue.links), json_value(&issue.links),
json_value(&issue.agent_refs), json_value(&issue.agent_refs),
json_value(&issue.attachments),
json_value(&issue.created_by), json_value(&issue.created_by),
json_value(&issue.updated_by), json_value(&issue.updated_by),
issue.created_at, 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 sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
let links: Vec<IssueLink> = parse_json_field(&map, "links")?; let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?; 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 created_by: IssueActor = parse_json_field(&map, "createdBy")?;
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
let created_at = parse_plain_u64(&map, "createdAt")?; let created_at = parse_plain_u64(&map, "createdAt")?;
@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
carnet: MarkdownDoc::default(), carnet: MarkdownDoc::default(),
links, links,
agent_refs, agent_refs,
attachments,
created_by, created_by,
updated_by, updated_by,
created_at, created_at,

View File

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

View File

@ -292,6 +292,48 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false "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 { ToolDef {
name: "idea_ticket_link", name: "idea_ticket_link",
description: "Add a link from one ticket to another using optimistic concurrency.", 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_read",
"idea_ticket_list", "idea_ticket_list",
"idea_ticket_read_carnet", "idea_ticket_read_carnet",
"idea_ticket_attachment_read",
"idea_sprint_list", "idea_sprint_list",
"idea_template_list", "idea_template_list",
"idea_template_read", "idea_template_read",
@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_ticket_bulk_update_priority", "idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete", "idea_ticket_bulk_delete",
"idea_ticket_update_carnet", "idea_ticket_update_carnet",
"idea_ticket_attachment_add",
"idea_ticket_attachment_mark_summarized",
"idea_ticket_link", "idea_ticket_link",
"idea_ticket_unlink", "idea_ticket_unlink",
"idea_template_create", "idea_template_create",

View File

@ -2,9 +2,9 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use domain::{ use domain::{
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
MarkdownDoc, ProjectPath, SprintId, IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId,
}; };
use infrastructure::{FsIssueNumberAllocator, FsIssueStore}; use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid; use uuid::Uuid;
@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() {
assert_eq!(rows[0].sprint, Some(sprint_id)); 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] #[tokio::test]
async fn issue_store_delete_removes_files_and_index_entry() { async fn issue_store_delete_removes_files_and_index_entry() {
let tmp = TempDir::new(); let tmp = TempDir::new();

View File

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

View File

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

View File

@ -66,6 +66,7 @@ import type {
SystemPermissionSet, SystemPermissionSet,
TerminalSession, TerminalSession,
Ticket, Ticket,
TicketAttachmentContent,
TicketBulkResult, TicketBulkResult,
TicketCarnet, TicketCarnet,
TicketChat, TicketChat,
@ -179,6 +180,22 @@ function sameTicketCreator(
return true; 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 { export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>(); private listeners = new Set<(e: DomainEvent) => void>();
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
return "/home/user/mock-plugin.ideaplug"; 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>(); private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
/** Count of `confirmAppExit()` calls, for test assertions. */ /** Count of `confirmAppExit()` calls, for test assertions. */
confirmAppExitCallCount = 0; confirmAppExitCallCount = 0;
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */ /** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
private chatSessions = new Map<string, string>(); private chatSessions = new Map<string, string>();
private chatCounter = 0; private chatCounter = 0;
private attachmentCounter = 0;
constructor(private readonly system?: MockSystemGateway) {} constructor(private readonly system?: MockSystemGateway) {}
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
_seedTicket(projectId: string, ticket: Ticket): Ticket { _seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket); const stored = structuredClone(ticket);
if (stored.sprintId === undefined) stored.sprintId = null; if (stored.sprintId === undefined) stored.sprintId = null;
stored.attachments ??= [];
this.projectTickets(projectId).set(stored.ref, stored); this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set( this.counters.set(
projectId, projectId,
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
sprintId: null, sprintId: null,
links: [], links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])], assignedAgentIds: [...(input.assignedAgentIds ?? [])],
attachments: [],
createdBy: { kind: "user" }, createdBy: { kind: "user" },
updatedBy: { kind: "user" }, updatedBy: { kind: "user" },
createdAt: now, createdAt: now,
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(ticket); 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( async link(
projectId: string, projectId: string,
ref: string, ref: string,

View File

@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
return typeof result === "string" ? result : null; 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( async onAppExitWorkGuard(
handler: (state: AppExitWorkGuardState) => void, handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> { ): 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, ReplyChunk,
Sprint, Sprint,
Ticket, Ticket,
TicketAttachmentContent,
TicketBulkResult, TicketBulkResult,
TicketCarnet, TicketCarnet,
TicketChat, 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( async link(
projectId: string, projectId: string,
ref: string, ref: string,

View File

@ -1473,6 +1473,25 @@ export type TicketActor =
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "system" }; | { 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`). */ /** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
export interface TicketLink { export interface TicketLink {
targetRef: TicketRef; targetRef: TicketRef;
@ -1510,6 +1529,7 @@ export interface Ticket {
carnet?: string; carnet?: string;
links: TicketLink[]; links: TicketLink[];
assignedAgentIds: string[]; assignedAgentIds: string[];
attachments: TicketAttachment[];
createdBy: TicketActor; createdBy: TicketActor;
updatedBy: TicketActor; updatedBy: TicketActor;
createdAt: number; 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({ export function TicketDetail({
projectId, projectId,
ticketRef, ticketRef,
@ -389,6 +401,72 @@ export function TicketDetail({
</div> </div>
</Section> </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) ── */} {/* ── Links (F5) ── */}
<Section title="Linked tickets"> <Section title="Linked tickets">
{t.links.length === 0 ? ( {t.links.length === 0 ? (

View File

@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") }); ).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 () => { it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" }); ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
const t = await ticket.create(PROJECT_ID, { title: "Doomed" }); 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 () => { it("assigns only known project agents", async () => {
const known = await seedAgent(agent, "Backend"); const known = await seedAgent(agent, "Backend");
const t = await ticket.create(PROJECT_ID, { title: "Assignable" }); const t = await ticket.create(PROJECT_ID, { title: "Assignable" });

View File

@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
priority?: Ticket["priority"]; priority?: Ticket["priority"];
}) => Promise<boolean>; }) => Promise<boolean>;
saveCarnet: (carnet: string) => Promise<boolean>; saveCarnet: (carnet: string) => Promise<boolean>;
attachFile: () => Promise<boolean>;
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>; link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>; assign: (agentId: string, assigned: boolean) => Promise<boolean>;
@ -193,6 +195,26 @@ export function useTicketDetail(
[run, gateway, projectId, ref], [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( const link: TicketDetailViewModel["link"] = useCallback(
(targetRef, kind) => (targetRef, kind) =>
run((version) => gateway.link(projectId, ref, targetRef, kind, version)), run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
@ -243,6 +265,8 @@ export function useTicketDetail(
refresh, refresh,
updateFields, updateFields,
saveCarnet, saveCarnet,
attachFile,
markAttachmentSummarized,
link, link,
unlink, unlink,
assign, assign,

View File

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

View File

@ -68,6 +68,7 @@ import type {
TerminalSession, TerminalSession,
Ticket, Ticket,
TicketBulkResult, TicketBulkResult,
TicketAttachmentContent,
TicketChat, TicketChat,
TicketCarnet, TicketCarnet,
TicketLinkKind, TicketLinkKind,
@ -96,6 +97,12 @@ export interface SystemGateway {
* cancelled. Same sanctioned-picker rule as {@link pickFolder}. * cancelled. Same sanctioned-picker rule as {@link pickFolder}.
*/ */
pickArchiveFile(): Promise<string | null>; 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 * Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
* closing the main window is intercepted because it would interrupt active * closing the main window is intercepted because it would interrupt active
@ -1096,6 +1103,27 @@ export interface TicketGateway {
carnet: string, carnet: string,
expectedVersion: number, expectedVersion: number,
): Promise<Ticket>; ): 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. */ /** Links this ticket to another `#id` with the given relationship kind. */
link( link(
projectId: string, projectId: string,