diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index abb4fb9..98df2bb 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -285,6 +285,9 @@ pub fn run() { tickets::close_ticket_chat, tickets::ticket_list, tickets::ticket_update, + tickets::ticket_attachment_add, + tickets::ticket_attachment_read, + tickets::ticket_attachment_mark_summarized, tickets::ticket_read_carnet, tickets::ticket_update_carnet, tickets::ticket_link, diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 3bc76f0..443c5da 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -336,6 +336,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" + | "idea_ticket_attachment_add" + | "idea_ticket_attachment_mark_summarized" | "idea_ticket_link" | "idea_ticket_unlink" ) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 7357abb..7c3c6b7 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -71,6 +71,9 @@ impl AppState { bulk_delete: Arc::clone(&core.bulk_delete_issues), read_carnet: Arc::clone(&core.read_issue_carnet), update_carnet: Arc::clone(&core.update_issue_carnet), + add_attachment: Arc::clone(&core.add_issue_attachment), + read_attachment: Arc::clone(&core.read_issue_attachment), + mark_attachment_summarized: Arc::clone(&core.mark_issue_attachment_summarized), link: Arc::clone(&core.link_issues), unlink: Arc::clone(&core.unlink_issues), list_sprints: Arc::clone(&core.list_sprints), diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index f944405..07f602c 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -11,9 +11,10 @@ use serde_json::{json, Value}; use tauri::State; use application::{ - AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, - CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, - ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, + AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput, + CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, + LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput, + OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, }; @@ -34,6 +35,9 @@ pub struct AppTicketToolProvider { pub bulk_delete: Arc, pub read_carnet: Arc, pub update_carnet: Arc, + pub add_attachment: Arc, + pub read_attachment: Arc, + pub mark_attachment_summarized: Arc, pub link: Arc, pub unlink: Arc, pub list_sprints: Arc, @@ -251,6 +255,57 @@ impl TicketToolProvider for AppTicketToolProvider { Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) )) } + "idea_ticket_attachment_add" => { + let req = mcp_attachment_add_request(project, arguments)?; + let issue = self + .add_attachment + .execute(AddIssueAttachmentInput { + project: project.clone(), + issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?, + path: req.path, + mime: req.mime, + expected_version: domain::IssueVersion::new(req.expected_version) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_attachment_read" => { + let req = mcp_attachment_read_request(project, arguments)?; + let content = self + .read_attachment + .execute(ReadIssueAttachmentInput { + project: project.clone(), + issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?, + attachment_id: parse_attachment_id_dto(&req.attachment_id) + .map_err(dto_tool_error)?, + }) + .await + .map_err(ticket_error)? + .content; + json!(TicketAttachmentContentDto::from(content)) + } + "idea_ticket_attachment_mark_summarized" => { + let req = mcp_attachment_mark_summarized_request(project, arguments)?; + let issue = self + .mark_attachment_summarized + .execute(MarkIssueAttachmentSummarizedInput { + project: project.clone(), + issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?, + attachment_id: parse_attachment_id_dto(&req.attachment_id) + .map_err(dto_tool_error)?, + expected_version: domain::IssueVersion::new(req.expected_version) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } "idea_ticket_link" => { let issue = self .link @@ -348,6 +403,78 @@ pub async fn ticket_read( Ok(TicketDto::from_issue(issue, carnet)) } +#[tauri::command] +pub async fn ticket_attachment_add( + request: TicketAttachmentAddRequestDto, + state: State<'_, AppState>, +) -> Result { + 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 { + 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 { + let project = resolve_project(&state, &request.project_id).await?; + let issue = state + .mark_issue_attachment_summarized + .execute(MarkIssueAttachmentSummarizedInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + attachment_id: parse_attachment_id_dto(&request.attachment_id)?, + expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| { + ErrorDto { + code: "INVALID".to_owned(), + message: e.to_string(), + } + })?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + #[tauri::command] pub async fn ticket_delete( request: TicketDeleteRequestDto, @@ -876,6 +1003,36 @@ fn mcp_bulk_delete_request( Ok(req) } +fn mcp_attachment_add_request( + project: &Project, + arguments: Value, +) -> Result { + 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 { + 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 { + 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 { required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error)) } diff --git a/crates/application/src/issues/mod.rs b/crates/application/src/issues/mod.rs index 7889135..ca5527f 100644 --- a/crates/application/src/issues/mod.rs +++ b/crates/application/src/issues/mod.rs @@ -3,17 +3,22 @@ //! The domain/code term is `Issue` throughout this layer. use std::collections::HashSet; +use std::path::Path; use std::sync::Arc; use domain::{AgentContextStore, Clock, EventBus, IdGenerator}; use domain::{ - AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, - IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator, - IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project, + AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueAttachmentContent, + IssueAttachmentId, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind, + IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, + IssueVersion, LocalPath, MarkdownDoc, Project, }; use crate::error::AppError; +/// Maximum raw attachment size for the minimal ticket attachment lot. +pub const TICKET_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; + /// Input for [`CreateIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateIssueInput { @@ -720,6 +725,199 @@ impl UpdateIssueCarnet { } } +/// Input for [`AddIssueAttachment::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddIssueAttachmentInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, + /// Local source file path. + pub path: String, + /// Optional MIME type supplied by the driving adapter. + pub mime: Option, + /// 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, + ids: Arc, + clock: Arc, + events: Arc, +} + +impl AddIssueAttachment { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + ids: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + ids, + clock, + events, + } + } + + /// Executes attachment add. + pub async fn execute( + &self, + input: AddIssueAttachmentInput, + ) -> Result { + 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, +} + +impl ReadIssueAttachment { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc) -> Self { + Self { issues } + } + + /// Executes attachment read. + pub async fn execute( + &self, + input: ReadIssueAttachmentInput, + ) -> Result { + 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, + clock: Arc, + events: Arc, +} + +impl MarkIssueAttachmentSummarized { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes summary mark. + pub async fn execute( + &self, + input: MarkIssueAttachmentSummarizedInput, + ) -> Result { + let issue = self + .issues + .mark_attachment_summarized( + &input.project.root, + input.issue_ref, + &input.attachment_id, + input.actor, + now(&self.clock), + input.expected_version, + ) + .await?; + self.events.publish(DomainEvent::IssueUpdated { + issue_ref: input.issue_ref, + version: issue.version, + }); + Ok(MarkIssueAttachmentSummarizedOutput { issue }) + } +} + /// Input for [`LinkIssues::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LinkIssuesInput { @@ -1030,6 +1228,75 @@ fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec) { } } +fn filename_from_path(path: &str) -> Result { + 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 { + let mime = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| infer_mime(filename).to_owned()); + if mime.eq_ignore_ascii_case("application/x-msdownload") + || mime.eq_ignore_ascii_case("application/x-sh") + || mime.eq_ignore_ascii_case("application/x-executable") + { + return Err(AppError::Invalid( + "executable attachments are not allowed".to_owned(), + )); + } + Ok(mime) +} + +fn infer_mime(filename: &str) -> &'static str { + match filename + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .as_deref() + { + Some("txt" | "md" | "log") => "text/plain", + Some("json") => "application/json", + Some("xml") => "application/xml", + Some("jpg" | "jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("pdf") => "application/pdf", + Some("csv") => "text/csv", + _ => "application/octet-stream", + } +} + fn publish_update_events( events: &Arc, issue: &Issue, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 737b7ac..81e0d85 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -96,14 +96,18 @@ pub use git::{ }; pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use issues::{ - AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, - BulkDeleteIssues, BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority, + AddIssueAttachment, AddIssueAttachmentInput, AddIssueAttachmentOutput, AssignIssueAgent, + AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, BulkDeleteIssues, + BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, - ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, - ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet, - UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput, + MarkIssueAttachmentSummarized, MarkIssueAttachmentSummarizedInput, + MarkIssueAttachmentSummarizedOutput, ReadIssue, ReadIssueAttachment, ReadIssueAttachmentInput, + ReadIssueAttachmentOutput, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, + ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, + UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, + UpdateIssueOutput, TICKET_ATTACHMENT_MAX_BYTES, }; pub use layout::{ CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index 163424d..60be252 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -139,6 +139,8 @@ impl OpenTicketAssistant { "idea_ticket_update_priority".to_owned(), "idea_ticket_read_carnet".to_owned(), "idea_ticket_update_carnet".to_owned(), + "idea_ticket_attachment_read".to_owned(), + "idea_ticket_attachment_mark_summarized".to_owned(), "idea_ticket_link".to_owned(), "idea_ticket_unlink".to_owned(), ], diff --git a/crates/application/tests/issue_usecases.rs b/crates/application/tests/issue_usecases.rs index 3c665ea..0a595f9 100644 --- a/crates/application/tests/issue_usecases.rs +++ b/crates/application/tests/issue_usecases.rs @@ -8,9 +8,10 @@ use domain::ports::{ IssueStoreError, StoreError, }; use domain::{ - AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry, - IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, - ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef, + AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueAttachmentContent, + IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, + IssueRef, IssueStatus, IssueVersion, LocalPath, ManifestEntry, MarkdownDoc, ProfileId, Project, + ProjectId, ProjectPath, RemoteRef, }; use uuid::Uuid; @@ -139,6 +140,42 @@ impl IssueStore for FakeIssues { self.update(_root, &updated, expected_version).await?; self.read_carnet(_root, issue_ref).await } + + async fn add_attachment_from_path( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _source: &LocalPath, + _attachment_id: IssueAttachmentId, + _filename: String, + _mime: String, + _actor: IssueActor, + _now_ms: u64, + _expected_version: IssueVersion, + ) -> Result { + unimplemented!("not needed") + } + + async fn read_attachment( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _attachment_id: &IssueAttachmentId, + ) -> Result { + 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 { + unimplemented!("not needed") + } } struct SeqAllocator(Mutex); diff --git a/crates/application/tests/sprint_usecases.rs b/crates/application/tests/sprint_usecases.rs index 99e1444..d072af9 100644 --- a/crates/application/tests/sprint_usecases.rs +++ b/crates/application/tests/sprint_usecases.rs @@ -4,10 +4,11 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError}; use domain::{ - DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter, - IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project, - ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder, - SprintStatus, SprintStore, SprintStoreError, SprintVersion, + DomainEvent, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet, + IssueId, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, + IssueVersion, LocalPath, MarkdownDoc, Project, ProjectId, ProjectPath, RemoteRef, Sprint, + SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError, + SprintVersion, }; use uuid::Uuid; @@ -198,6 +199,42 @@ impl IssueStore for FakeIssues { ) -> Result { 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 { + unimplemented!("not needed") + } + + async fn read_attachment( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _attachment_id: &IssueAttachmentId, + ) -> Result { + 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 { + unimplemented!("not needed") + } } struct SeqIds(Mutex); diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 67e8f8d..4651d40 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -17,11 +17,11 @@ use domain::profile::StructuredAdapter; use domain::{ AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus, - EventStream, Issue, IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, - IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, - NetworkPolicy, PermissionSet, PreparedContext, ProfileId, ProfileStore, Project, ProjectId, - ProjectPath, ProjectPermissions, ProjectSystemPermissions, RemoteRef, SessionId, - SystemPermissionSet, + EventStream, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet, + IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueStore, + IssueStoreError, IssueVersion, LocalPath, MarkdownDoc, NetworkPolicy, PermissionSet, + PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath, ProjectPermissions, + ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet, }; use uuid::Uuid; @@ -138,6 +138,42 @@ impl IssueStore for FakeIssues { ) -> Result { 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 { + unimplemented!() + } + + async fn read_attachment( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _attachment_id: &IssueAttachmentId, + ) -> Result { + unimplemented!() + } + + async fn mark_attachment_summarized( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _attachment_id: &IssueAttachmentId, + _actor: IssueActor, + _now_ms: u64, + _expected_version: IssueVersion, + ) -> Result { + unimplemented!() + } } struct FakeProfiles { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 0e9e7c0..58342bc 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -12,15 +12,15 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ - AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, - AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, - BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, - CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, - ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, - CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, - CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, - DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent, + AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, + BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, + CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, + CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, + CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, + DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, @@ -31,12 +31,13 @@ use application::{ ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, + ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions, @@ -997,6 +998,12 @@ pub struct BackendCore { pub read_issue_carnet: Arc, /// Update a ticket carnet. pub update_issue_carnet: Arc, + /// Attach a local file to a ticket. + pub add_issue_attachment: Arc, + /// Read one ticket attachment. + pub read_issue_attachment: Arc, + /// Mark a ticket attachment as summarized in the carnet. + pub mark_issue_attachment_summarized: Arc, /// Link two public tickets. pub link_issues: Arc, /// Unlink public tickets. @@ -1696,6 +1703,19 @@ impl BackendCore { Arc::clone(&clock) as Arc, Arc::clone(&events_port), )); + let add_issue_attachment = Arc::new(AddIssueAttachment::new( + Arc::clone(&issue_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + 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, + Arc::clone(&events_port), + )); let link_issues = Arc::new(LinkIssues::new( Arc::clone(&issue_store_port), Arc::clone(&clock) as Arc, @@ -2843,6 +2863,9 @@ impl BackendCore { bulk_delete_issues, read_issue_carnet, update_issue_carnet, + add_issue_attachment, + read_issue_attachment, + mark_issue_attachment_summarized, link_issues, unlink_issues, assign_issue_agent, diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 7391453..e59e81b 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -349,6 +349,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" + | "idea_ticket_attachment_add" + | "idea_ticket_attachment_mark_summarized" | "idea_ticket_link" | "idea_ticket_unlink" ) diff --git a/crates/backend/src/ticket_dto.rs b/crates/backend/src/ticket_dto.rs index b003a89..cafb7b3 100644 --- a/crates/backend/src/ticket_dto.rs +++ b/crates/backend/src/ticket_dto.rs @@ -10,9 +10,10 @@ use application::{ BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput, }; use domain::{ - AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, - IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, - Project, SprintId, SprintStatus, SprintVersion, + AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, + IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, + IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus, + SprintVersion, }; use infrastructure::TicketToolError; @@ -36,6 +37,7 @@ pub struct TicketDto { pub carnet: Option, pub links: Vec, pub assigned_agent_ids: Vec, + pub attachments: Vec, pub created_by: TicketActorDto, pub updated_by: TicketActorDto, pub created_at: u64, @@ -69,6 +71,31 @@ pub struct TicketListDto { pub next_cursor: Option, } +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub summarized_at: Option, +} + +/// Raw attachment content DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketAttachmentContentDto { + pub attachment: TicketAttachmentDto, + pub content_base64: String, +} + /// Sprint context embedded in ticket read/list responses. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto { pub r#ref: String, } +/// Add attachment request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketAttachmentAddRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub path: String, + pub mime: Option, + pub expected_version: u64, +} + +/// Read attachment request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketAttachmentReadRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub attachment_id: String, +} + +/// Mark attachment summarized request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketAttachmentMarkSummarizedRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub attachment_id: String, + pub expected_version: u64, +} + /// List request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -437,6 +497,11 @@ impl TicketDto { .filter(|r| r.role == AgentIssueRole::Assigned) .map(|r| r.agent_id.to_string()) .collect(), + attachments: issue + .attachments + .into_iter() + .map(TicketAttachmentDto::from) + .collect(), created_by: TicketActorDto::from(issue.created_by), updated_by: TicketActorDto::from(issue.updated_by), created_at: issue.created_at, @@ -547,6 +612,31 @@ impl From for TicketActorDto { } } +impl From 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 for TicketAttachmentContentDto { + fn from(content: IssueAttachmentContent) -> Self { + Self { + attachment: TicketAttachmentDto::from(content.attachment), + content_base64: URL_SAFE_NO_PAD.encode(content.bytes), + } + } +} + impl TryFrom for IssueActor { type Error = ErrorDto; @@ -560,6 +650,13 @@ impl TryFrom for IssueActor { } } +pub fn parse_attachment_id_dto(raw: &str) -> Result { + IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto { + code: "INVALID".to_owned(), + message: err.to_string(), + }) +} + impl From for TicketCarnetDto { fn from(carnet: IssueCarnet) -> Self { Self { diff --git a/crates/domain/src/agent_tool_policy.rs b/crates/domain/src/agent_tool_policy.rs index ff89d8a..03635c0 100644 --- a/crates/domain/src/agent_tool_policy.rs +++ b/crates/domain/src/agent_tool_policy.rs @@ -63,6 +63,8 @@ fn is_ticket_mutation_tool(tool: &str) -> bool { | "idea_ticket_bulk_update_priority" | "idea_ticket_bulk_delete" | "idea_ticket_update_carnet" + | "idea_ticket_attachment_add" + | "idea_ticket_attachment_mark_summarized" | "idea_ticket_link" | "idea_ticket_unlink" | "idea_ticket_create" diff --git a/crates/domain/src/issue.rs b/crates/domain/src/issue.rs index 95ca3b1..0e6da95 100644 --- a/crates/domain/src/issue.rs +++ b/crates/domain/src/issue.rs @@ -11,6 +11,35 @@ use thiserror::Error; use crate::ids::{AgentId, IssueId, SprintId}; use crate::markdown::MarkdownDoc; +/// Stable ticket attachment identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct IssueAttachmentId(String); + +impl IssueAttachmentId { + /// Builds an attachment id. + /// + /// # Errors + /// [`IssueError::InvalidAttachmentId`] when the id is empty or not filename-safe. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(IssueError::InvalidAttachmentId(value)); + } + Ok(Self(value)) + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + /// Sequential per-project issue number. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -239,6 +268,35 @@ pub enum IssueActor { System, } +/// Metadata for a file attached to a ticket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueAttachment { + /// Stable attachment id. + pub id: IssueAttachmentId, + /// Original display filename. + pub filename: String, + /// Stored relative path under the ticket directory. + pub path: String, + /// MIME type. + pub mime: String, + /// Size in bytes. + pub size_bytes: u64, + /// Actor that added the attachment. + pub added_by: IssueActor, + /// Add time, epoch milliseconds. + pub added_at: u64, + /// Whether an agent/user summarized the attachment into the ticket carnet. + #[serde(default)] + pub summarized_in_carnet: bool, + /// Actor that marked it summarized. + #[serde(default)] + pub summarized_by: Option, + /// Summary mark time, epoch milliseconds. + #[serde(default)] + pub summarized_at: Option, +} + /// Issue aggregate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -263,6 +321,9 @@ pub struct Issue { pub links: Vec, /// Agent references. pub agent_refs: Vec, + /// Attached files. + #[serde(default)] + pub attachments: Vec, /// Creator. pub created_by: IssueActor, /// Last updater. @@ -305,6 +366,7 @@ impl Issue { carnet, links, agent_refs, + attachments: Vec::new(), created_by: actor.clone(), updated_by: actor, created_at: now_ms, @@ -344,6 +406,34 @@ impl Issue { if self.links.iter().any(|link| link.target == own) { return Err(IssueError::SelfLink { reference: own }); } + let mut attachment_ids = std::collections::HashSet::new(); + for attachment in &self.attachments { + if !attachment_ids.insert(attachment.id.clone()) { + return Err(IssueError::DuplicateAttachmentId( + attachment.id.as_str().to_owned(), + )); + } + if attachment.filename.trim().is_empty() + || attachment.filename.contains('/') + || attachment.filename.contains('\\') + || attachment.filename == "." + || attachment.filename == ".." + { + return Err(IssueError::InvalidAttachmentFilename( + attachment.filename.clone(), + )); + } + if attachment.path.starts_with('/') + || attachment.path.starts_with('\\') + || attachment.path.contains("..") + || !attachment.path.starts_with("attachments/") + { + return Err(IssueError::InvalidAttachmentPath(attachment.path.clone())); + } + if attachment.mime.trim().is_empty() { + return Err(IssueError::InvalidAttachmentMime(attachment.mime.clone())); + } + } Ok(()) } @@ -469,4 +559,19 @@ pub enum IssueError { /// Self reference. reference: IssueRef, }, + /// Attachment id is not flat filename-safe. + #[error("invalid attachment id: {0}")] + InvalidAttachmentId(String), + /// Attachment filename is empty or not flat. + #[error("invalid attachment filename: {0}")] + InvalidAttachmentFilename(String), + /// Attachment stored path is invalid. + #[error("invalid attachment path: {0}")] + InvalidAttachmentPath(String), + /// Attachment MIME type is invalid. + #[error("invalid attachment mime: {0}")] + InvalidAttachmentMime(String), + /// Attachment ids must be unique per issue. + #[error("duplicate attachment id: {0}")] + DuplicateAttachmentId(String), } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index f6cd8f0..0bd19a7 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -127,9 +127,9 @@ pub use inbox::{ }; pub use issue::{ - AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry, - IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, - IssueVersion, + AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentId, + IssueCarnet, IssueError, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, + IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, }; pub use sprint::{ @@ -237,15 +237,15 @@ pub use ports::{ DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, - IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath, - McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, - ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, - Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, - PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, - PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError, - ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, - PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, - ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, - StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, - TemplateStore, WindowStateStore, + IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError, + LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, + MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, + ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, + PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, + PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, + PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, + ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, + RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, + SprintStoreError, StoreError, StructuredSessionEnvironment, + StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 4a84d27..8ee845b 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -40,7 +40,8 @@ use crate::ids::{ AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, }; use crate::issue::{ - Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, + Issue, IssueAttachment, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, + IssueNumber, IssueRef, IssueVersion, }; use crate::markdown::MarkdownDoc; use crate::mcp_tool_permissions::ProjectMcpToolPermissions; @@ -980,6 +981,15 @@ pub enum IssueStoreError { Store(String), } +/// Raw ticket attachment content. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueAttachmentContent { + /// Attachment metadata. + pub attachment: IssueAttachment, + /// Raw bytes. + pub bytes: Vec, +} + /// Errors from the sprint store. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SprintStoreError { @@ -2360,6 +2370,40 @@ pub trait IssueStore: Send + Sync { now_ms: u64, expected_version: IssueVersion, ) -> Result; + + /// 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; + + /// Reads an attachment's raw bytes. + async fn read_attachment( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + attachment_id: &IssueAttachmentId, + ) -> Result; + + /// 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; } /// Persistence port for project-scoped sprints. diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs index f5fe02b..692c7ff 100644 --- a/crates/infrastructure/src/issues.rs +++ b/crates/infrastructure/src/issues.rs @@ -14,20 +14,23 @@ use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use domain::{ - AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, - IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, - IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId, + AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId, + IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber, + IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, + IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId, }; const IDEAI_DIR: &str = ".ideai"; const ISSUES_DIR: &str = "tickets"; const ISSUE_FILE: &str = "issue.md"; const CARNET_FILE: &str = "carnet.md"; +const ATTACHMENTS_DIR: &str = "attachments"; const INDEX_FILE: &str = "index.json"; const COUNTER_FILE: &str = "counter.json"; const COUNTER_LOCK: &str = "counter.lock"; const MUTATION_LOCK: &str = "mutation.lock"; const INDEX_VERSION: u32 = 1; +const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; /// Filesystem-backed issue store. #[derive(Debug, Clone, Default)] @@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf { issue_dir(root, number).join(CARNET_FILE) } +fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf { + issue_dir(root, number).join(relative) +} + fn index_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(INDEX_FILE) } @@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError { IssueStoreError::Store(err.to_string()) } +fn attachment_by_id( + issue: &Issue, + id: &IssueAttachmentId, +) -> Result { + issue + .attachments + .iter() + .find(|attachment| &attachment.id == id) + .cloned() + .ok_or(IssueStoreError::NotFound) +} + async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result { let number = issue_ref.number(); let issue_text = read_string(&issue_path(root, number)).await?; @@ -451,6 +470,158 @@ impl IssueStore for FsIssueStore { let _ = tokio::fs::remove_file(&lock_path).await; result } + + async fn add_attachment_from_path( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + source: &LocalPath, + attachment_id: IssueAttachmentId, + filename: String, + mime: String, + actor: IssueActor, + now_ms: u64, + expected_version: IssueVersion, + ) -> Result { + 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 { + 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 { + let lock_path = mutation_lock_path(root); + let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; + let result = async { + let current = load_issue(root, issue_ref).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + if !current + .attachments + .iter() + .any(|attachment| &attachment.id == attachment_id) + { + return Err(IssueStoreError::NotFound); + } + let updated = current + .mutate(actor.clone(), now_ms, |issue| { + if let Some(attachment) = issue + .attachments + .iter_mut() + .find(|attachment| &attachment.id == attachment_id) + { + attachment.summarized_in_carnet = true; + attachment.summarized_by = Some(actor); + attachment.summarized_at = Some(now_ms); + } + }) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + save_issue(root, &updated).await?; + rebuild_index(root).await?; + Ok(updated) + } + .await; + let _ = tokio::fs::remove_file(&lock_path).await; + result + } } #[async_trait] @@ -509,6 +680,7 @@ priority: {}\n\ sprint: {}\n\ links: {}\n\ agentRefs: {}\n\ +attachments: {}\n\ createdBy: {}\n\ updatedBy: {}\n\ createdAt: {}\n\ @@ -523,6 +695,7 @@ version: {}\n\ json_value(&issue.sprint), json_value(&issue.links), json_value(&issue.agent_refs), + json_value(&issue.attachments), json_value(&issue.created_by), json_value(&issue.updated_by), issue.created_at, @@ -563,6 +736,8 @@ fn parse_issue_doc(text: &str) -> Result { let sprint: Option = parse_optional_json_field(&map, "sprint")?.unwrap_or(None); let links: Vec = parse_json_field(&map, "links")?; let agent_refs: Vec = parse_json_field(&map, "agentRefs")?; + let attachments: Vec = + parse_optional_json_field(&map, "attachments")?.unwrap_or_default(); let created_by: IssueActor = parse_json_field(&map, "createdBy")?; let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; let created_at = parse_plain_u64(&map, "createdAt")?; @@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result { carnet: MarkdownDoc::default(), links, agent_refs, + attachments, created_by, updated_by, created_at, diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index abf210a..bd3605f 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -911,6 +911,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" + | "idea_ticket_attachment_add" + | "idea_ticket_attachment_mark_summarized" | "idea_ticket_link" | "idea_ticket_unlink" ) diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index 4142338..8ea3739 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -292,6 +292,48 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_ticket_attachment_add", + description: "Attach a local file to a ticket using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "path": { "type": "string" }, + "mime": { "type": "string" }, + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "path", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_attachment_read", + description: "Read one ticket attachment as base64 content.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "attachmentId": { "type": "string" } + }, + "required": ["ref", "attachmentId"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_attachment_mark_summarized", + description: "Mark a ticket attachment as summarized in the carnet.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "attachmentId": { "type": "string" }, + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "attachmentId", "expectedVersion"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_ticket_link", description: "Add a link from one ticket to another using optimistic concurrency.", diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index e0cecfe..17e770e 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -53,6 +53,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[ "idea_ticket_read", "idea_ticket_list", "idea_ticket_read_carnet", + "idea_ticket_attachment_read", "idea_sprint_list", "idea_template_list", "idea_template_read", @@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_ticket_bulk_update_priority", "idea_ticket_bulk_delete", "idea_ticket_update_carnet", + "idea_ticket_attachment_add", + "idea_ticket_attachment_mark_summarized", "idea_ticket_link", "idea_ticket_unlink", "idea_template_create", diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs index 8196999..b78941c 100644 --- a/crates/infrastructure/tests/issue_store.rs +++ b/crates/infrastructure/tests/issue_store.rs @@ -2,9 +2,9 @@ use std::path::PathBuf; use std::str::FromStr; use domain::{ - AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, - IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, - MarkdownDoc, ProjectPath, SprintId, + AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId, + IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, + IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId, }; use infrastructure::{FsIssueNumberAllocator, FsIssueStore}; use uuid::Uuid; @@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() { assert_eq!(rows[0].sprint, Some(sprint_id)); } +#[tokio::test] +async fn issue_store_adds_reads_and_marks_attachment_summarized() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + let issue = issue(&root, 7, "Attachment ticket"); + store.create(&root, &issue).await.unwrap(); + let source = tmp.child("source-note.txt"); + std::fs::write(&source, "attachment payload").unwrap(); + let attachment_id = IssueAttachmentId::new("att_1".to_owned()).unwrap(); + + let updated = store + .add_attachment_from_path( + &root, + issue.reference(), + &LocalPath::new(source.to_string_lossy().to_string()), + attachment_id.clone(), + "source-note.txt".to_owned(), + "text/plain".to_owned(), + IssueActor::User, + 2_000, + issue.version, + ) + .await + .unwrap(); + + assert_eq!(updated.attachments.len(), 1); + assert_eq!(updated.attachments[0].filename, "source-note.txt"); + assert_eq!(updated.attachments[0].mime, "text/plain"); + assert_eq!(updated.attachments[0].size_bytes, 18); + assert!(!updated.attachments[0].summarized_in_carnet); + assert!(tmp + .child(".ideai/tickets/7/attachments/att_1-source-note.txt") + .exists()); + + let content = store + .read_attachment(&root, issue.reference(), &attachment_id) + .await + .unwrap(); + assert_eq!(content.attachment.id, attachment_id); + assert_eq!(content.bytes, b"attachment payload"); + + let summarized = store + .mark_attachment_summarized( + &root, + issue.reference(), + &attachment_id, + IssueActor::System, + 3_000, + updated.version, + ) + .await + .unwrap(); + + let attachment = &summarized.attachments[0]; + assert!(attachment.summarized_in_carnet); + assert_eq!(attachment.summarized_by, Some(IssueActor::System)); + assert_eq!(attachment.summarized_at, Some(3_000)); +} + #[tokio::test] async fn issue_store_delete_removes_files_and_index_entry() { let tmp = TempDir::new(); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 8d55841..4b4cf23 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -34,20 +34,21 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, - AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput, - CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, - DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, - DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, - GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, - GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, - InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, - ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, - ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision, + AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput, + AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput, + ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput, + CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput, + DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, + DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput, + GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, + GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, + LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, + ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, + MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, - ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, - ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput, - ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, + ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, + RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, @@ -64,33 +65,36 @@ use domain::{ }; use backend::dto::{ - create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id, - parse_profile_id, parse_project_id, parse_ref_dto, parse_session_id, parse_skill_id, - parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id, - sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, - AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, - BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, - CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto, - CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto, - DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, - EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, - GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, - MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, - ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, - ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, - ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto, - SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, - SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, - TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, - TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto, + parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto, + parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, + parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto, + AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, + AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, + ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, + CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, + GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, + GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, + InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, + MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, + ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, + ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, + RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto, + ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, + SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, + SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, + SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, + TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, + TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, + TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, + TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, @@ -2478,6 +2482,11 @@ async fn invoke( "ticket_read" => invoke_ticket_read(&request.args, &state.app).await, "ticket_list" => invoke_ticket_list(&request.args, &state.app).await, "ticket_update" => invoke_ticket_update(&request.args, &state.app).await, + "ticket_attachment_add" => invoke_ticket_attachment_add(&request.args, &state.app).await, + "ticket_attachment_read" => invoke_ticket_attachment_read(&request.args, &state.app).await, + "ticket_attachment_mark_summarized" => { + invoke_ticket_attachment_mark_summarized(&request.args, &state.app).await + } "ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await, "ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await, "ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await, @@ -3050,6 +3059,72 @@ async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result Result { + let request = required_request::("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 { + let request = + required_request::("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 { + let request = required_request::( + "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 { let request = required_request::("ticket_delete", args)?; let project = resolve_project_readonly(&request.project_id, state).await?; diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index ea40883..d198c35 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -29,6 +29,7 @@ import type { Sprint, TerminalSession, Ticket, + TicketAttachmentContent, TicketBulkResult, TicketCarnet, TicketChat, @@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway { return unsupportedOnWeb("Native file picker"); } + pickFile(): Promise { + // Desktop-only, same rationale as `pickFolder`. + return unsupportedOnWeb("Native file picker"); + } + onAppExitWorkGuard( _handler: (state: AppExitWorkGuardState) => void, ): Promise { @@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway { request: { projectId, ref, carnet, expectedVersion }, }); } + addAttachment( + projectId: string, + ref: string, + path: string, + expectedVersion: number, + mime?: string | null, + ): Promise { + return this.http.invoke("ticket_attachment_add", { + request: { projectId, ref, path, expectedVersion, mime }, + }); + } + readAttachment( + projectId: string, + ref: string, + attachmentId: string, + ): Promise { + return this.http.invoke("ticket_attachment_read", { + request: { projectId, ref, attachmentId }, + }); + } + markAttachmentSummarized( + projectId: string, + ref: string, + attachmentId: string, + expectedVersion: number, + ): Promise { + return this.http.invoke("ticket_attachment_mark_summarized", { + request: { projectId, ref, attachmentId, expectedVersion }, + }); + } link( projectId: string, ref: string, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f66d72d..fe6bbc1 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -66,6 +66,7 @@ import type { SystemPermissionSet, TerminalSession, Ticket, + TicketAttachmentContent, TicketBulkResult, TicketCarnet, TicketChat, @@ -179,6 +180,22 @@ function sameTicketCreator( return true; } +function filenameFromPath(path: string): string { + return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment"; +} + +function inferAttachmentMime(filename: string, supplied?: string | null): string { + if (supplied?.trim()) return supplied.trim(); + const lower = filename.toLowerCase(); + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".pdf")) return "application/pdf"; + return "application/octet-stream"; +} + export class MockSystemGateway implements SystemGateway { private listeners = new Set<(e: DomainEvent) => void>(); @@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway { return "/home/user/mock-plugin.ideaplug"; } + /** Returns a deterministic fake path — never opens a native dialog. */ + async pickFile(): Promise { + return "/home/user/mock-attachment.txt"; + } + private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>(); /** Count of `confirmAppExit()` calls, for test assertions. */ confirmAppExitCallCount = 0; @@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway { /** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */ private chatSessions = new Map(); private chatCounter = 0; + private attachmentCounter = 0; constructor(private readonly system?: MockSystemGateway) {} @@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway { _seedTicket(projectId: string, ticket: Ticket): Ticket { const stored = structuredClone(ticket); if (stored.sprintId === undefined) stored.sprintId = null; + stored.attachments ??= []; this.projectTickets(projectId).set(stored.ref, stored); this.counters.set( projectId, @@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway { sprintId: null, links: [], assignedAgentIds: [...(input.assignedAgentIds ?? [])], + attachments: [], createdBy: { kind: "user" }, updatedBy: { kind: "user" }, createdAt: now, @@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway { return structuredClone(ticket); } + async addAttachment( + projectId: string, + ref: string, + path: string, + expectedVersion: number, + mime?: string | null, + ): Promise { + 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 { + 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 { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + const attachment = ticket.attachments.find((item) => item.id === attachmentId); + if (!attachment) { + throw { + code: "NOT_FOUND", + message: `attachment ${attachmentId} not found`, + } as GatewayError; + } + attachment.summarizedInCarnet = true; + attachment.summarizedBy = { kind: "user" }; + attachment.summarizedAt = Date.now(); + this.bump(ticket); + this.system?.emit({ + type: "issueUpdated", + issueRef: ref, + version: ticket.version, + }); + return structuredClone(ticket); + } + async link( projectId: string, ref: string, diff --git a/frontend/src/adapters/system.ts b/frontend/src/adapters/system.ts index c2d9f02..2c3acc8 100644 --- a/frontend/src/adapters/system.ts +++ b/frontend/src/adapters/system.ts @@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway { return typeof result === "string" ? result : null; } + async pickFile(): Promise { + const result = await open({ directory: false, multiple: false }); + return typeof result === "string" ? result : null; + } + async onAppExitWorkGuard( handler: (state: AppExitWorkGuardState) => void, ): Promise { diff --git a/frontend/src/adapters/ticket.test.ts b/frontend/src/adapters/ticket.test.ts index 519e4a8..c29cce6 100644 --- a/frontend/src/adapters/ticket.test.ts +++ b/frontend/src/adapters/ticket.test.ts @@ -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, + }, + }, + ); + }); }); diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 324a80d..1a4be20 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -17,6 +17,7 @@ import type { ReplyChunk, Sprint, Ticket, + TicketAttachmentContent, TicketBulkResult, TicketCarnet, TicketChat, @@ -134,6 +135,39 @@ export class TauriTicketGateway implements TicketGateway { }); } + async addAttachment( + projectId: string, + ref: string, + path: string, + expectedVersion: number, + mime?: string | null, + ): Promise { + return invoke("ticket_attachment_add", { + request: { projectId, ref, path, expectedVersion, mime }, + }); + } + + async readAttachment( + projectId: string, + ref: string, + attachmentId: string, + ): Promise { + return invoke("ticket_attachment_read", { + request: { projectId, ref, attachmentId }, + }); + } + + async markAttachmentSummarized( + projectId: string, + ref: string, + attachmentId: string, + expectedVersion: number, + ): Promise { + return invoke("ticket_attachment_mark_summarized", { + request: { projectId, ref, attachmentId, expectedVersion }, + }); + } + async link( projectId: string, ref: string, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 07ffa4b..e5a20dc 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1473,6 +1473,25 @@ export type TicketActor = | { kind: "agent"; agentId: string } | { kind: "system" }; +/** Metadata for a file attached to a ticket (mirror of `TicketAttachmentDto`). */ +export interface TicketAttachment { + id: string; + filename: string; + mime: string; + sizeBytes: number; + addedBy: TicketActor; + addedAt: number; + summarizedInCarnet: boolean; + summarizedBy?: TicketActor | null; + summarizedAt?: number | null; +} + +/** Raw ticket attachment content (mirror of `TicketAttachmentContentDto`). */ +export interface TicketAttachmentContent { + attachment: TicketAttachment; + contentBase64: string; +} + /** One directed link from a ticket to another (mirror of `TicketLinkDto`). */ export interface TicketLink { targetRef: TicketRef; @@ -1510,6 +1529,7 @@ export interface Ticket { carnet?: string; links: TicketLink[]; assignedAgentIds: string[]; + attachments: TicketAttachment[]; createdBy: TicketActor; updatedBy: TicketActor; createdAt: number; diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx index 44906fd..3003d1d 100644 --- a/frontend/src/features/tickets/TicketDetail.tsx +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -76,6 +76,18 @@ function Section({ ); } +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + export function TicketDetail({ projectId, ticketRef, @@ -389,6 +401,72 @@ export function TicketDetail({ + {/* ── Attachments (#108) ── */} +
+
+

+ {t.attachments.length === 0 + ? "Aucune pièce jointe." + : `${t.attachments.length} pièce${ + t.attachments.length > 1 ? "s" : "" + } jointe${t.attachments.length > 1 ? "s" : ""}.`} +

+ +
+ {t.attachments.length > 0 && ( +
    + {t.attachments.map((attachment) => ( +
  • +
    +

    + {attachment.filename} +

    +

    + {formatBytes(attachment.sizeBytes)} ·{" "} + {attachment.mime || "application/octet-stream"} +

    +
    +
    + + Résumé : {attachment.summarizedInCarnet ? "oui" : "non"} + + {!attachment.summarizedInCarnet && ( + + )} +
    +
  • + ))} +
+ )} +
+ {/* ── Links (F5) ── */}
{t.links.length === 0 ? ( diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index 3982966..9cf8cf7 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -172,6 +172,46 @@ describe("MockTicketGateway", () => { ).rejects.toMatchObject({ message: expect.stringContaining("version conflict") }); }); + it("adds ticket attachments and marks them summarized (#108)", async () => { + const t = await ticket.create(PROJECT_ID, { title: "With attachment" }); + + const attached = await ticket.addAttachment( + PROJECT_ID, + t.ref, + "/tmp/spec.pdf", + t.version, + "application/pdf", + ); + expect(attached.version).toBe(2); + expect(attached.attachments).toMatchObject([ + { + filename: "spec.pdf", + mime: "application/pdf", + summarizedInCarnet: false, + }, + ]); + + const content = await ticket.readAttachment( + PROJECT_ID, + t.ref, + attached.attachments[0].id, + ); + expect(content.attachment.filename).toBe("spec.pdf"); + expect(content.contentBase64).toBeTruthy(); + + const summarized = await ticket.markAttachmentSummarized( + PROJECT_ID, + t.ref, + attached.attachments[0].id, + attached.version, + ); + expect(summarized.version).toBe(3); + expect(summarized.attachments[0]).toMatchObject({ + summarizedInCarnet: true, + summarizedBy: { kind: "user" }, + }); + }); + it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => { ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" }); const t = await ticket.create(PROJECT_ID, { title: "Doomed" }); @@ -515,6 +555,36 @@ describe("TicketsView", () => { }); }); + it("shows, adds, and marks ticket attachments from the detail (#108)", async () => { + const t = await ticket.create(PROJECT_ID, { title: "Attachable" }); + renderView(ticket, system, agent); + + fireEvent.click(await screen.findByText("Attachable")); + const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` }); + + expect(within(dialog).getByText("Pièces jointes")).toBeTruthy(); + expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy(); + + fireEvent.click(within(dialog).getByText("Joindre")); + + expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy(); + expect(within(dialog).getByText(/text\/plain/)).toBeTruthy(); + expect(within(dialog).getByText("Résumé : non")).toBeTruthy(); + + const attached = await ticket.read(PROJECT_ID, t.ref); + expect(attached.attachments).toHaveLength(1); + + fireEvent.click( + within(dialog).getByLabelText("marquer résumé mock-attachment.txt"), + ); + + expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy(); + await waitFor(async () => { + const fresh = await ticket.read(PROJECT_ID, t.ref); + expect(fresh.attachments[0].summarizedInCarnet).toBe(true); + }); + }); + it("assigns only known project agents", async () => { const known = await seedAgent(agent, "Backend"); const t = await ticket.create(PROJECT_ID, { title: "Assignable" }); diff --git a/frontend/src/features/tickets/useTicketDetail.ts b/frontend/src/features/tickets/useTicketDetail.ts index e9d68f6..8882f3d 100644 --- a/frontend/src/features/tickets/useTicketDetail.ts +++ b/frontend/src/features/tickets/useTicketDetail.ts @@ -45,6 +45,8 @@ export interface TicketDetailViewModel { priority?: Ticket["priority"]; }) => Promise; saveCarnet: (carnet: string) => Promise; + attachFile: () => Promise; + markAttachmentSummarized: (attachmentId: string) => Promise; link: (targetRef: string, kind: TicketLinkKind) => Promise; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise; assign: (agentId: string, assigned: boolean) => Promise; @@ -193,6 +195,26 @@ export function useTicketDetail( [run, gateway, projectId, ref], ); + const attachFile: TicketDetailViewModel["attachFile"] = useCallback(async () => { + if (!system) return false; + try { + const path = await system.pickFile(); + if (!path) return false; + return run((version) => gateway.addAttachment(projectId, ref, path, version)); + } catch (e) { + setError(describe(e)); + return false; + } + }, [system, run, gateway, projectId, ref]); + + const markAttachmentSummarized: TicketDetailViewModel["markAttachmentSummarized"] = useCallback( + (attachmentId) => + run((version) => + gateway.markAttachmentSummarized(projectId, ref, attachmentId, version), + ), + [run, gateway, projectId, ref], + ); + const link: TicketDetailViewModel["link"] = useCallback( (targetRef, kind) => run((version) => gateway.link(projectId, ref, targetRef, kind, version)), @@ -243,6 +265,8 @@ export function useTicketDetail( refresh, updateFields, saveCarnet, + attachFile, + markAttachmentSummarized, link, unlink, assign, diff --git a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx index 879d027..9e9476d 100644 --- a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx +++ b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx @@ -28,6 +28,7 @@ function ticket(over: Partial = {}): Ticket { sprintId: null, links: [], assignedAgentIds: [], + attachments: [], createdBy: { kind: "user" }, updatedBy: { kind: "user" }, createdAt: 1, diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 1acc0fd..a3ef41a 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -68,6 +68,7 @@ import type { TerminalSession, Ticket, TicketBulkResult, + TicketAttachmentContent, TicketChat, TicketCarnet, TicketLinkKind, @@ -96,6 +97,12 @@ export interface SystemGateway { * cancelled. Same sanctioned-picker rule as {@link pickFolder}. */ pickArchiveFile(): Promise; + /** + * 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; /** * Subscribes to the app-exit work-in-progress guard (ticket #83): fired when * closing the main window is intercepted because it would interrupt active @@ -1096,6 +1103,27 @@ export interface TicketGateway { carnet: string, expectedVersion: number, ): Promise; + /** 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; + /** Reads raw attachment content as base64. Not used by the minimal v1 UI. */ + readAttachment( + projectId: string, + ref: string, + attachmentId: string, + ): Promise; + /** Marks an attachment as summarized in the ticket carnet. */ + markAttachmentSummarized( + projectId: string, + ref: string, + attachmentId: string, + expectedVersion: number, + ): Promise; /** Links this ticket to another `#id` with the given relationship kind. */ link( projectId: string,