feat(attachments): fondation pipeline durable d'attachments agent/chat + sandbox-safe (#154, QA verte)
Socle #154 : entité attachment, store, contrat DTO et routage session. Modules : domain/chat_attachment, application/chat_attachments, infrastructure/chat_attachments, app-tauri (commands/lib), backend dto.
This commit is contained in:
@ -7,7 +7,6 @@
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
@ -31,7 +30,7 @@ use application::{
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, TICKET_ATTACHMENT_MAX_BYTES,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use backend::stream::OutputSink;
|
||||
use domain::ports::ModelServerRuntime;
|
||||
@ -45,34 +44,36 @@ use crate::dto::{
|
||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||
MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
@ -2159,6 +2160,7 @@ pub async fn agent_send(
|
||||
session_id: String,
|
||||
prompt: String,
|
||||
attachment_paths: Option<Vec<String>>,
|
||||
attachments: Option<Vec<ChatAttachmentInputDto>>,
|
||||
on_reply: Channel<ReplyChunk>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
@ -2175,9 +2177,14 @@ pub async fn agent_send(
|
||||
// pump (if any) is superseded and stops delivering to its stale channel.
|
||||
let gen = state.chat_bridge.register(sid, on_reply);
|
||||
|
||||
let staged_attachments =
|
||||
stage_chat_attachments(&state, &sid, attachment_paths.unwrap_or_default()).await?;
|
||||
let prompt_for_model = prompt_with_staged_attachments(&prompt, &staged_attachments);
|
||||
let imported_attachments = import_chat_attachments_for_session(
|
||||
&state,
|
||||
&sid,
|
||||
attachment_paths.unwrap_or_default(),
|
||||
attachments.unwrap_or_default(),
|
||||
)
|
||||
.await?;
|
||||
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
|
||||
|
||||
// Retain the human submit in the same live scrollback as model chunks so
|
||||
// `reattach_agent_chat` can repaint the whole in-flight conversation.
|
||||
@ -2262,12 +2269,39 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stage_chat_attachments(
|
||||
/// `import_chat_attachments` — imports user-supplied files into durable,
|
||||
/// sandbox-readable storage for a live structured chat session.
|
||||
///
|
||||
/// This command exposes the same backend pipeline used by [`agent_send`] without
|
||||
/// starting a model turn, so frontend code can preflight/import attachments and
|
||||
/// render the durable metadata before submitting the prompt.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed ids, missing live session metadata, or
|
||||
/// invalid/unreadable source files.
|
||||
#[tauri::command]
|
||||
pub async fn import_chat_attachments(
|
||||
request: ImportChatAttachmentsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ImportChatAttachmentsResponseDto, ErrorDto> {
|
||||
let sid = parse_session_id(&request.session_id)?;
|
||||
let attachments = import_chat_attachments_for_session(
|
||||
&state,
|
||||
&sid,
|
||||
request.attachment_paths,
|
||||
request.attachments,
|
||||
)
|
||||
.await?;
|
||||
Ok(ImportChatAttachmentsResponseDto { attachments })
|
||||
}
|
||||
|
||||
async fn import_chat_attachments_for_session(
|
||||
state: &AppState,
|
||||
session_id: &domain::SessionId,
|
||||
paths: Vec<String>,
|
||||
) -> Result<Vec<PathBuf>, ErrorDto> {
|
||||
if paths.is_empty() {
|
||||
legacy_paths: Vec<String>,
|
||||
attachments: Vec<ChatAttachmentInputDto>,
|
||||
) -> Result<Vec<ChatAttachmentDto>, ErrorDto> {
|
||||
if legacy_paths.is_empty() && attachments.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (project_id, agent_id, _, _) = state
|
||||
@ -2287,82 +2321,64 @@ async fn stage_chat_attachments(
|
||||
.find(|project| project.id == project_id)
|
||||
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
|
||||
|
||||
let stage_dir = Path::new(project.root.as_str())
|
||||
.join(".ideai")
|
||||
.join("run")
|
||||
.join(agent_id.to_string())
|
||||
.join("attachments")
|
||||
.join(session_id.to_string());
|
||||
if tokio::fs::metadata(&stage_dir).await.is_ok() {
|
||||
tokio::fs::remove_dir_all(&stage_dir)
|
||||
.await
|
||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
||||
let mut seen_paths = std::collections::HashSet::new();
|
||||
let mut items = Vec::with_capacity(legacy_paths.len() + attachments.len());
|
||||
for path in legacy_paths {
|
||||
if seen_paths.insert(path.clone()) {
|
||||
items.push(application::ImportChatAttachmentItem {
|
||||
path,
|
||||
mime: None,
|
||||
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
|
||||
});
|
||||
}
|
||||
tokio::fs::create_dir_all(&stage_dir)
|
||||
.await
|
||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
||||
|
||||
let mut staged = Vec::with_capacity(paths.len());
|
||||
for (index, raw_path) in paths.iter().enumerate() {
|
||||
let source = PathBuf::from(raw_path);
|
||||
let meta = tokio::fs::metadata(&source)
|
||||
.await
|
||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(ErrorDto::from(AppError::Invalid(
|
||||
"chat attachment source must be a file".to_owned(),
|
||||
)));
|
||||
}
|
||||
if meta.len() > TICKET_ATTACHMENT_MAX_BYTES {
|
||||
return Err(ErrorDto::from(AppError::Invalid(format!(
|
||||
"chat attachment exceeds {} bytes",
|
||||
TICKET_ATTACHMENT_MAX_BYTES
|
||||
))));
|
||||
for attachment in attachments {
|
||||
if seen_paths.insert(attachment.path.clone()) {
|
||||
items.push(application::ImportChatAttachmentItem {
|
||||
path: attachment.path,
|
||||
mime: attachment.mime,
|
||||
source_kind: attachment
|
||||
.source_kind
|
||||
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
|
||||
});
|
||||
}
|
||||
let filename = source
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| valid_chat_attachment_filename(name))
|
||||
.ok_or_else(|| {
|
||||
ErrorDto::from(AppError::Invalid(
|
||||
"invalid chat attachment filename".to_owned(),
|
||||
))
|
||||
})?;
|
||||
let dest = stage_dir.join(format!("{index}-{filename}"));
|
||||
tokio::fs::copy(&source, &dest)
|
||||
.await
|
||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
||||
staged.push(dest);
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
fn valid_chat_attachment_filename(filename: &str) -> bool {
|
||||
let lowered = filename.to_ascii_lowercase();
|
||||
let blocked = [
|
||||
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
|
||||
"deb", "rpm",
|
||||
];
|
||||
!lowered.trim().is_empty()
|
||||
&& !lowered.contains('/')
|
||||
&& !lowered.contains('\\')
|
||||
&& lowered != "."
|
||||
&& lowered != ".."
|
||||
&& !lowered
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(_, ext)| blocked.contains(&ext))
|
||||
let output = state
|
||||
.import_chat_attachments
|
||||
.execute(application::ImportChatAttachmentsInput {
|
||||
project,
|
||||
agent_id,
|
||||
session_id: *session_id,
|
||||
attachments: items,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(output
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(ChatAttachmentDto::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn prompt_with_staged_attachments(prompt: &str, staged: &[PathBuf]) -> String {
|
||||
if staged.is_empty() {
|
||||
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
|
||||
if attachments.is_empty() {
|
||||
return prompt.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(prompt.len() + staged.len() * 96);
|
||||
let mut out = String::with_capacity(prompt.len() + attachments.len() * 160);
|
||||
out.push_str(prompt);
|
||||
out.push_str("\n\nPièces jointes copiées dans le run dir de cette session :\n");
|
||||
for path in staged {
|
||||
out.push_str("- ");
|
||||
out.push_str(&path.to_string_lossy());
|
||||
out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n");
|
||||
for attachment in attachments {
|
||||
out.push_str("- id: ");
|
||||
out.push_str(&attachment.id);
|
||||
out.push_str("; file: ");
|
||||
out.push_str(&attachment.filename);
|
||||
out.push_str("; mime: ");
|
||||
out.push_str(&attachment.mime);
|
||||
out.push_str("; bytes: ");
|
||||
out.push_str(&attachment.size_bytes.to_string());
|
||||
out.push_str("; path: ");
|
||||
out.push_str(&attachment.readable_path);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
|
||||
@ -341,6 +341,7 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::import_chat_attachments,
|
||||
commands::cancel_resume,
|
||||
commands::set_resume_at,
|
||||
commands::interrupt_agent,
|
||||
|
||||
@ -6,11 +6,14 @@
|
||||
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
|
||||
//! `cellKind` and PTY paths keep `"pty"`.
|
||||
|
||||
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
|
||||
use app_tauri_lib::dto::{
|
||||
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto,
|
||||
ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, TerminalSessionDto,
|
||||
};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::SessionId;
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{ChatAttachmentSourceKind, SessionId};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -103,6 +106,84 @@ fn reply_chunk_rejects_snake_case_tag() {
|
||||
assert!(r.is_err(), "snake_case kind must not deserialise");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat attachments — structured input + durable metadata, camelCase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_input_dto_deserialises_camel_case() {
|
||||
let dto: ChatAttachmentInputDto = serde_json::from_value(json!({
|
||||
"path": "/tmp/picked.png",
|
||||
"mime": "image/png",
|
||||
"sourceKind": "clipboard"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.path, "/tmp/picked.png");
|
||||
assert_eq!(dto.mime.as_deref(), Some("image/png"));
|
||||
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_dto_serialises_camel_case() {
|
||||
let dto = ChatAttachmentDto {
|
||||
id: "attach-1".to_owned(),
|
||||
filename: "picked.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
size_bytes: 9,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
storage_path: "agent-chat/session/attach-1-picked.png".to_owned(),
|
||||
readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png"
|
||||
.to_owned(),
|
||||
created_at: 42,
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(dto).unwrap();
|
||||
assert_eq!(v["sizeBytes"], 9);
|
||||
assert_eq!(v["sourceKind"], "localFile");
|
||||
assert_eq!(v["storagePath"], "agent-chat/session/attach-1-picked.png");
|
||||
assert!(v.get("readable_path").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_chat_attachments_request_response_use_camel_case() {
|
||||
let request: ImportChatAttachmentsRequestDto = serde_json::from_value(json!({
|
||||
"sessionId": "sess-1",
|
||||
"attachmentPaths": ["/tmp/legacy.txt"],
|
||||
"attachments": [{
|
||||
"path": "/tmp/picked.png",
|
||||
"mime": "image/png",
|
||||
"sourceKind": "dragDrop"
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.session_id, "sess-1");
|
||||
assert_eq!(request.attachment_paths, vec!["/tmp/legacy.txt"]);
|
||||
assert_eq!(
|
||||
request.attachments[0].source_kind,
|
||||
Some(ChatAttachmentSourceKind::DragDrop)
|
||||
);
|
||||
|
||||
let response = ImportChatAttachmentsResponseDto {
|
||||
attachments: vec![ChatAttachmentDto {
|
||||
id: "attach-1".to_owned(),
|
||||
filename: "picked.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
size_bytes: 9,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
storage_path: "agent-chat/session/attach-1-picked.png".to_owned(),
|
||||
readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png"
|
||||
.to_owned(),
|
||||
created_at: 42,
|
||||
}],
|
||||
};
|
||||
let v = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(v["attachments"][0]["sizeBytes"], 9);
|
||||
assert!(v.get("attachment_paths").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReattachChatDto — typed scrollback, camelCase (zone 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
173
crates/application/src/chat_attachments.rs
Normal file
173
crates/application/src/chat_attachments.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! Agent/chat attachment use cases.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{ChatAttachmentImport, ChatAttachmentStore};
|
||||
use domain::{
|
||||
AgentId, ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind, Clock, IdGenerator,
|
||||
LocalPath, Project, SessionId,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Maximum raw chat attachment size.
|
||||
pub const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Input for [`ImportChatAttachments::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentsInput {
|
||||
/// Project owning the chat session.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// Structured/chat session id.
|
||||
pub session_id: SessionId,
|
||||
/// Attachments to import.
|
||||
pub attachments: Vec<ImportChatAttachmentItem>,
|
||||
}
|
||||
|
||||
/// One source attachment import request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentItem {
|
||||
/// Local source path supplied by the driving adapter.
|
||||
pub path: String,
|
||||
/// Optional MIME type supplied by the driving adapter.
|
||||
pub mime: Option<String>,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
}
|
||||
|
||||
/// Output of [`ImportChatAttachments::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentsOutput {
|
||||
/// Imported durable attachments.
|
||||
pub attachments: Vec<ChatAttachment>,
|
||||
}
|
||||
|
||||
/// Imports user-supplied files into durable, sandbox-readable chat attachment storage.
|
||||
pub struct ImportChatAttachments {
|
||||
store: Arc<dyn ChatAttachmentStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ImportChatAttachments {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ChatAttachmentStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { store, ids, clock }
|
||||
}
|
||||
|
||||
/// Executes chat attachment imports.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ImportChatAttachmentsInput,
|
||||
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
||||
let mut imported = Vec::with_capacity(input.attachments.len());
|
||||
for item in input.attachments {
|
||||
let filename = filename_from_path(&item.path)?;
|
||||
validate_attachment_filename(&filename)?;
|
||||
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
|
||||
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let attachment = self
|
||||
.store
|
||||
.import_from_path(
|
||||
&input.project.root,
|
||||
input.project.id,
|
||||
input.agent_id,
|
||||
input.session_id,
|
||||
&LocalPath::new(item.path),
|
||||
ChatAttachmentImport {
|
||||
id,
|
||||
filename,
|
||||
mime,
|
||||
source_kind: item.source_kind,
|
||||
created_at: now(&self.clock),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
imported.push(attachment);
|
||||
}
|
||||
Ok(ImportChatAttachmentsOutput {
|
||||
attachments: imported,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn now(clock: &Arc<dyn Clock>) -> u64 {
|
||||
u64::try_from(clock.now_millis()).unwrap_or(0)
|
||||
}
|
||||
|
||||
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("invalid attachment 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",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,9 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||
ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError,
|
||||
AgentSessionError, ChatAttachmentStoreError, EmbedderError, FsError, GitError, MemoryError,
|
||||
ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
@ -168,6 +169,16 @@ impl From<SprintStoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChatAttachmentStoreError> for AppError {
|
||||
fn from(e: ChatAttachmentStoreError) -> Self {
|
||||
match e {
|
||||
ChatAttachmentStoreError::NotFound => Self::NotFound("chat attachment".to_owned()),
|
||||
ChatAttachmentStoreError::Invalid(message) => Self::Invalid(message),
|
||||
ChatAttachmentStoreError::Store(message) => Self::Store(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod background;
|
||||
pub mod chat_attachments;
|
||||
pub mod conversation;
|
||||
pub mod device;
|
||||
pub mod diag;
|
||||
@ -70,6 +71,10 @@ pub use background::{
|
||||
RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput,
|
||||
SpawnBackgroundCommandOutput,
|
||||
};
|
||||
pub use chat_attachments::{
|
||||
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
|
||||
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
|
||||
167
crates/application/tests/chat_attachments.rs
Normal file
167
crates/application/tests/chat_attachments.rs
Normal file
@ -0,0 +1,167 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, Clock, IdGenerator,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, ChatAttachment, ChatAttachmentSourceKind, LocalPath, Project, ProjectId, ProjectPath,
|
||||
RemoteRef, SessionId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeChatAttachments {
|
||||
calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatAttachmentStore for FakeChatAttachments {
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((source.clone(), import.clone()));
|
||||
let storage_path = format!(
|
||||
"agent-chat/{session_id}/{}-{}",
|
||||
import.id.as_str(),
|
||||
import.filename
|
||||
);
|
||||
let readable_path = format!(
|
||||
"{}/.ideai/attachments/agent-chat/{session_id}/{}-{}",
|
||||
root.as_str(),
|
||||
import.id.as_str(),
|
||||
import.filename
|
||||
);
|
||||
Ok(ChatAttachment {
|
||||
id: import.id,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
filename: import.filename.clone(),
|
||||
mime: import.mime.clone(),
|
||||
size_bytes: 12,
|
||||
source_kind: import.source_kind,
|
||||
storage_path,
|
||||
readable_path,
|
||||
created_at: import.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
1_234
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedIds;
|
||||
|
||||
impl IdGenerator for FixedIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()),
|
||||
"P",
|
||||
ProjectPath::new("/tmp/idea-chat-attachments-test").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
|
||||
let store = Arc::new(FakeChatAttachments::default());
|
||||
let usecase = ImportChatAttachments::new(
|
||||
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let agent_id =
|
||||
AgentId::from_uuid(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap());
|
||||
let session_id =
|
||||
SessionId::from_uuid(Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap());
|
||||
|
||||
let output = usecase
|
||||
.execute(ImportChatAttachmentsInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
session_id,
|
||||
attachments: vec![ImportChatAttachmentItem {
|
||||
path: "/outside/photo.png".to_owned(),
|
||||
mime: None,
|
||||
source_kind: ChatAttachmentSourceKind::Clipboard,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.attachments.len(), 1);
|
||||
let attachment = &output.attachments[0];
|
||||
assert_eq!(attachment.filename, "photo.png");
|
||||
assert_eq!(attachment.mime, "image/png");
|
||||
assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard);
|
||||
assert_eq!(attachment.created_at, 1_234);
|
||||
assert!(attachment
|
||||
.storage_path
|
||||
.starts_with("agent-chat/cccccccc-cccc-cccc-cccc-cccccccccccc/"));
|
||||
assert!(attachment
|
||||
.readable_path
|
||||
.contains("/.ideai/attachments/agent-chat/"));
|
||||
|
||||
let calls = store.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0.as_str(), "/outside/photo.png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
||||
let store = Arc::new(FakeChatAttachments::default());
|
||||
let usecase = ImportChatAttachments::new(
|
||||
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(ImportChatAttachmentsInput {
|
||||
project: project(),
|
||||
agent_id: AgentId::new_random(),
|
||||
session_id: SessionId::new_random(),
|
||||
attachments: vec![ImportChatAttachmentItem {
|
||||
path: "/outside/run.sh".to_owned(),
|
||||
mime: None,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(store.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
@ -2966,6 +2966,79 @@ impl From<TerminalSession> for TerminalSessionDto {
|
||||
// Structured chat sessions (§17 — D4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured attachment intent sent by the chat frontend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatAttachmentInputDto {
|
||||
/// Local source path selected or staged by the driving adapter.
|
||||
pub path: String,
|
||||
/// Optional MIME type known by the frontend/OS picker.
|
||||
#[serde(default)]
|
||||
pub mime: Option<String>,
|
||||
/// Origin kind (`localFile`, `clipboard`, `dragDrop`, `other`).
|
||||
#[serde(default)]
|
||||
pub source_kind: Option<domain::ChatAttachmentSourceKind>,
|
||||
}
|
||||
|
||||
/// Request for importing durable chat attachments before or during a structured turn.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportChatAttachmentsRequestDto {
|
||||
/// Live structured/chat session id.
|
||||
pub session_id: String,
|
||||
/// Legacy local source paths, kept for transition compatibility.
|
||||
#[serde(default)]
|
||||
pub attachment_paths: Vec<String>,
|
||||
/// Structured attachment intents.
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<ChatAttachmentInputDto>,
|
||||
}
|
||||
|
||||
/// Response returned after durable chat attachment import.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportChatAttachmentsResponseDto {
|
||||
/// Imported durable attachments.
|
||||
pub attachments: Vec<ChatAttachmentDto>,
|
||||
}
|
||||
|
||||
/// Durable chat attachment metadata returned by the backend/application layer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatAttachmentDto {
|
||||
/// Stable attachment id.
|
||||
pub id: String,
|
||||
/// Original display filename.
|
||||
pub filename: String,
|
||||
/// MIME type.
|
||||
pub mime: String,
|
||||
/// Size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Origin kind.
|
||||
pub source_kind: domain::ChatAttachmentSourceKind,
|
||||
/// Stored relative path under `.ideai/attachments`.
|
||||
pub storage_path: String,
|
||||
/// Absolute managed path readable by the target agent sandbox.
|
||||
pub readable_path: String,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl From<domain::ChatAttachment> for ChatAttachmentDto {
|
||||
fn from(value: domain::ChatAttachment) -> Self {
|
||||
Self {
|
||||
id: value.id.as_str().to_owned(),
|
||||
filename: value.filename,
|
||||
mime: value.mime,
|
||||
size_bytes: value.size_bytes,
|
||||
source_kind: value.source_kind,
|
||||
storage_path: value.storage_path,
|
||||
readable_path: value.readable_path,
|
||||
created_at: value.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One incremental chunk of a structured agent reply, streamed over the chat
|
||||
/// session's adapter-owned channel. The serialised wire twin of a
|
||||
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
|
||||
|
||||
@ -26,45 +26,46 @@ use application::{
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments,
|
||||
PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics,
|
||||
PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, 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, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
|
||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
||||
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||
ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles,
|
||||
ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
|
||||
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||
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,
|
||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
||||
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder,
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
||||
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
@ -89,19 +90,20 @@ use infrastructure::{
|
||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||
FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
@ -1014,6 +1016,8 @@ pub struct BackendCore {
|
||||
pub read_issue_attachment: Arc<ReadIssueAttachment>,
|
||||
/// Mark a ticket attachment as summarized in the carnet.
|
||||
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
||||
/// Import durable agent/chat attachments.
|
||||
pub import_chat_attachments: Arc<ImportChatAttachments>,
|
||||
/// Link two public tickets.
|
||||
pub link_issues: Arc<LinkIssues>,
|
||||
/// Unlink public tickets.
|
||||
@ -1696,6 +1700,9 @@ impl BackendCore {
|
||||
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
|
||||
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
|
||||
let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
|
||||
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
|
||||
let chat_attachment_store_port =
|
||||
Arc::clone(&chat_attachment_store) as Arc<dyn ChatAttachmentStore>;
|
||||
let create_issue = Arc::new(CreateIssue::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&issue_allocator_port),
|
||||
@ -1749,6 +1756,11 @@ impl BackendCore {
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let import_chat_attachments = Arc::new(ImportChatAttachments::new(
|
||||
Arc::clone(&chat_attachment_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let link_issues = Arc::new(LinkIssues::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
@ -2978,6 +2990,7 @@ impl BackendCore {
|
||||
add_issue_attachment,
|
||||
read_issue_attachment,
|
||||
mark_issue_attachment_summarized,
|
||||
import_chat_attachments,
|
||||
link_issues,
|
||||
unlink_issues,
|
||||
assign_issue_agent,
|
||||
|
||||
132
crates/domain/src/chat_attachment.rs
Normal file
132
crates/domain/src/chat_attachment.rs
Normal file
@ -0,0 +1,132 @@
|
||||
//! Agent/chat attachment domain model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, ProjectId, SessionId};
|
||||
|
||||
/// Stable chat attachment identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ChatAttachmentId(String);
|
||||
|
||||
impl ChatAttachmentId {
|
||||
/// Builds an attachment id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ChatAttachmentError::InvalidId`] when the id is empty or not filename-safe.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ChatAttachmentError> {
|
||||
let value = value.into();
|
||||
if value.is_empty()
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidId(value));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw id.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the attachment originally came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChatAttachmentSourceKind {
|
||||
/// User-selected local filesystem path.
|
||||
LocalFile,
|
||||
/// Clipboard paste, materialized by a driving adapter.
|
||||
Clipboard,
|
||||
/// Drag-and-drop, materialized by a driving adapter.
|
||||
DragDrop,
|
||||
/// Unknown or future source.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Durable metadata for one agent/chat attachment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatAttachment {
|
||||
/// Stable attachment id.
|
||||
pub id: ChatAttachmentId,
|
||||
/// Project that owns the managed attachment.
|
||||
pub project_id: ProjectId,
|
||||
/// Target agent for the chat flow.
|
||||
pub agent_id: AgentId,
|
||||
/// Structured/chat session that imported the file.
|
||||
pub session_id: SessionId,
|
||||
/// Original display filename.
|
||||
pub filename: String,
|
||||
/// MIME type.
|
||||
pub mime: String,
|
||||
/// Size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
/// Stored relative path under `.ideai/attachments`.
|
||||
pub storage_path: String,
|
||||
/// Absolute managed path readable by the target agent sandbox.
|
||||
pub readable_path: String,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl ChatAttachment {
|
||||
/// Validates persisted metadata.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ChatAttachmentError`] when an invariant is violated.
|
||||
pub fn validate(&self) -> Result<(), ChatAttachmentError> {
|
||||
if self.filename.trim().is_empty()
|
||||
|| self.filename.contains('/')
|
||||
|| self.filename.contains('\\')
|
||||
|| self.filename == "."
|
||||
|| self.filename == ".."
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidFilename(self.filename.clone()));
|
||||
}
|
||||
if self.mime.trim().is_empty() {
|
||||
return Err(ChatAttachmentError::InvalidMime(self.mime.clone()));
|
||||
}
|
||||
if self.storage_path.starts_with('/')
|
||||
|| self.storage_path.starts_with('\\')
|
||||
|| self.storage_path.contains("..")
|
||||
|| !self.storage_path.starts_with("agent-chat/")
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidStoragePath(
|
||||
self.storage_path.clone(),
|
||||
));
|
||||
}
|
||||
if self.readable_path.trim().is_empty() {
|
||||
return Err(ChatAttachmentError::InvalidReadablePath(
|
||||
self.readable_path.clone(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Attachment validation errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ChatAttachmentError {
|
||||
/// Invalid id.
|
||||
#[error("invalid chat attachment id: {0}")]
|
||||
InvalidId(String),
|
||||
/// Invalid filename.
|
||||
#[error("invalid chat attachment filename: {0}")]
|
||||
InvalidFilename(String),
|
||||
/// Invalid MIME.
|
||||
#[error("invalid chat attachment mime: {0}")]
|
||||
InvalidMime(String),
|
||||
/// Invalid relative storage path.
|
||||
#[error("invalid chat attachment storage path: {0}")]
|
||||
InvalidStoragePath(String),
|
||||
/// Invalid readable path.
|
||||
#[error("invalid chat attachment readable path: {0}")]
|
||||
InvalidReadablePath(String),
|
||||
}
|
||||
@ -33,6 +33,7 @@
|
||||
pub mod agent;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachment;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
pub mod device;
|
||||
@ -101,6 +102,10 @@ pub use background_task::{
|
||||
BACKGROUND_TASK_TEXT_MAX_BYTES,
|
||||
};
|
||||
|
||||
pub use chat_attachment::{
|
||||
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
||||
};
|
||||
|
||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy;
|
||||
use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
||||
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{
|
||||
@ -1066,6 +1067,57 @@ pub struct IssueAttachmentContent {
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Metadata supplied by a driving adapter when importing a chat attachment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChatAttachmentImport {
|
||||
/// Stable id allocated by the application layer.
|
||||
pub id: ChatAttachmentId,
|
||||
/// Original display filename.
|
||||
pub filename: String,
|
||||
/// MIME type.
|
||||
pub mime: String,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
/// Chat attachment store errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ChatAttachmentStoreError {
|
||||
/// The requested attachment does not exist.
|
||||
#[error("chat attachment not found")]
|
||||
NotFound,
|
||||
/// The attachment payload is invalid.
|
||||
#[error("chat attachment invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Store I/O or serialization failed.
|
||||
#[error("chat attachment store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped agent/chat attachments.
|
||||
#[async_trait]
|
||||
pub trait ChatAttachmentStore: Send + Sync {
|
||||
/// Copies a local source file into IdeA-managed durable attachment storage.
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
|
||||
|
||||
/// Lists attachments imported for a structured/chat session.
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
|
||||
}
|
||||
|
||||
/// Errors from the sprint store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SprintStoreError {
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ChatAttachment,
|
||||
ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind, ContextInjection, DomainError,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||
};
|
||||
@ -19,6 +20,23 @@ fn template_id() -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(7))
|
||||
}
|
||||
|
||||
fn chat_attachment() -> ChatAttachment {
|
||||
ChatAttachment {
|
||||
id: ChatAttachmentId::new("attach-1").unwrap(),
|
||||
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
agent_id: domain::AgentId::from_uuid(Uuid::from_u128(2)),
|
||||
session_id: domain::SessionId::from_uuid(Uuid::from_u128(3)),
|
||||
filename: "note.txt".to_owned(),
|
||||
mime: "text/plain".to_owned(),
|
||||
size_bytes: 12,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
storage_path: "agent-chat/session/attach-1-note.txt".to_owned(),
|
||||
readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-note.txt"
|
||||
.to_owned(),
|
||||
created_at: 123,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectPath
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -52,6 +70,26 @@ fn project_path_rejects_empty() {
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_metadata_accepts_agent_chat_storage_path() {
|
||||
assert!(chat_attachment().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_metadata_rejects_storage_path_outside_agent_chat_namespace() {
|
||||
let mut attachment = chat_attachment();
|
||||
attachment.storage_path = "tickets/1/file.txt".to_owned();
|
||||
|
||||
assert!(matches!(
|
||||
attachment.validate(),
|
||||
Err(ChatAttachmentError::InvalidStoragePath(_))
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project (also exercises the Clock/IdGenerator port fakes for determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
178
crates/infrastructure/src/chat_attachments.rs
Normal file
178
crates/infrastructure/src/chat_attachments.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! Filesystem chat attachment store.
|
||||
//!
|
||||
//! Project-scoped agent/chat attachments live under
|
||||
//! `<root>/.ideai/attachments/agent-chat/<session-id>/`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError};
|
||||
use domain::{AgentId, ChatAttachment, LocalPath, ProjectId, ProjectPath, SessionId};
|
||||
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
const ATTACHMENTS_DIR: &str = "attachments";
|
||||
const AGENT_CHAT_DIR: &str = "agent-chat";
|
||||
const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Filesystem-backed chat attachment store.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FsChatAttachmentStore;
|
||||
|
||||
impl FsChatAttachmentStore {
|
||||
/// Builds a store.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_root(root: &ProjectPath) -> PathBuf {
|
||||
PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(ATTACHMENTS_DIR)
|
||||
}
|
||||
|
||||
fn session_dir(root: &ProjectPath, session_id: SessionId) -> PathBuf {
|
||||
attachment_root(root)
|
||||
.join(AGENT_CHAT_DIR)
|
||||
.join(session_id.to_string())
|
||||
}
|
||||
|
||||
fn metadata_path(root: &ProjectPath, session_id: SessionId, id: &str) -> PathBuf {
|
||||
session_dir(root, session_id).join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
fn storage_relative(session_id: SessionId, stored_name: &str) -> String {
|
||||
format!("{AGENT_CHAT_DIR}/{session_id}/{stored_name}")
|
||||
}
|
||||
|
||||
fn readable_path(root: &ProjectPath, relative: &str) -> String {
|
||||
attachment_root(root)
|
||||
.join(relative)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> ChatAttachmentStoreError {
|
||||
ChatAttachmentStoreError::Store(err.to_string())
|
||||
}
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> {
|
||||
if filename.trim().is_empty()
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| filename == "."
|
||||
|| filename == ".."
|
||||
{
|
||||
return Err(ChatAttachmentStoreError::Invalid(
|
||||
"invalid chat attachment filename".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_metadata(
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
attachment: &ChatAttachment,
|
||||
) -> Result<(), ChatAttachmentStoreError> {
|
||||
let path = metadata_path(root, session_id, attachment.id.as_str());
|
||||
let bytes = serde_json::to_vec_pretty(attachment)
|
||||
.map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?;
|
||||
tokio::fs::write(path, bytes).await.map_err(io_error)
|
||||
}
|
||||
|
||||
async fn read_metadata(path: &Path) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
let bytes = tokio::fs::read(path).await.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
ChatAttachmentStoreError::NotFound
|
||||
} else {
|
||||
io_error(err)
|
||||
}
|
||||
})?;
|
||||
let attachment: ChatAttachment = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?;
|
||||
attachment
|
||||
.validate()
|
||||
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatAttachmentStore for FsChatAttachmentStore {
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
validate_filename(&import.filename)?;
|
||||
let source_path = PathBuf::from(source.as_str());
|
||||
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
||||
if !meta.is_file() {
|
||||
return Err(ChatAttachmentStoreError::Invalid(
|
||||
"chat attachment source must be a file".to_owned(),
|
||||
));
|
||||
}
|
||||
if meta.len() > CHAT_ATTACHMENT_MAX_BYTES {
|
||||
return Err(ChatAttachmentStoreError::Invalid(format!(
|
||||
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
let dir = session_dir(root, session_id);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
||||
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
|
||||
let dest = dir.join(&stored_name);
|
||||
tokio::fs::copy(&source_path, &dest)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
|
||||
let storage_path = storage_relative(session_id, &stored_name);
|
||||
let attachment = ChatAttachment {
|
||||
id: import.id,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
filename: import.filename,
|
||||
mime: import.mime,
|
||||
size_bytes: meta.len(),
|
||||
source_kind: import.source_kind,
|
||||
readable_path: readable_path(root, &storage_path),
|
||||
storage_path,
|
||||
created_at: import.created_at,
|
||||
};
|
||||
attachment
|
||||
.validate()
|
||||
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
|
||||
write_metadata(root, session_id, &attachment).await?;
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError> {
|
||||
let dir = session_dir(root, session_id);
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(io_error(err)),
|
||||
};
|
||||
let mut attachments = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
attachments.push(read_metadata(&path).await?);
|
||||
}
|
||||
attachments.sort_by_key(|attachment| attachment.created_at);
|
||||
Ok(attachments)
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachments;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -53,6 +54,7 @@ pub use background_task::{
|
||||
BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail,
|
||||
CommandBackgroundRunner,
|
||||
};
|
||||
pub use chat_attachments::FsChatAttachmentStore;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
|
||||
105
crates/infrastructure/tests/chat_attachments.rs
Normal file
105
crates/infrastructure/tests/chat_attachments.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use domain::ports::{
|
||||
ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, IdGenerator,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, ChatAttachmentId, ChatAttachmentSourceKind, LocalPath, ProjectId, ProjectPath,
|
||||
SessionId,
|
||||
};
|
||||
use infrastructure::{FsChatAttachmentStore, UuidGenerator};
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"idea-chat-attachment-{tag}-{}",
|
||||
UuidGenerator::new().new_uuid()
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path() {
|
||||
let project_root = temp_dir("project");
|
||||
let outside_root = temp_dir("outside");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside_root).await.unwrap();
|
||||
let source = outside_root.join("picked.png");
|
||||
tokio::fs::write(&source, b"png bytes").await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let project_id = ProjectId::new_random();
|
||||
let agent_id = AgentId::new_random();
|
||||
let session_id = SessionId::new_random();
|
||||
let attachment_id = ChatAttachmentId::new("attach-1").unwrap();
|
||||
|
||||
let attachment = store
|
||||
.import_from_path(
|
||||
&project_path,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
&LocalPath::new(source.to_string_lossy().into_owned()),
|
||||
ChatAttachmentImport {
|
||||
id: attachment_id,
|
||||
filename: "picked.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
created_at: 42,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attachment.project_id, project_id);
|
||||
assert_eq!(attachment.agent_id, agent_id);
|
||||
assert_eq!(attachment.session_id, session_id);
|
||||
assert_eq!(attachment.filename, "picked.png");
|
||||
assert_eq!(attachment.size_bytes, 9);
|
||||
assert!(attachment
|
||||
.storage_path
|
||||
.starts_with(&format!("agent-chat/{session_id}/")));
|
||||
assert!(attachment
|
||||
.readable_path
|
||||
.starts_with(&format!("{}/.ideai/attachments/", project_root.display())));
|
||||
let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap();
|
||||
assert_eq!(stored_bytes, b"png bytes");
|
||||
|
||||
let listed = store
|
||||
.list_for_session(&project_path, session_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed, vec![attachment]);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_rejects_directories() {
|
||||
let project_root = temp_dir("project-dir");
|
||||
let outside_root = temp_dir("outside-dir");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside_root).await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let err = store
|
||||
.import_from_path(
|
||||
&ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
ProjectId::new_random(),
|
||||
AgentId::new_random(),
|
||||
SessionId::new_random(),
|
||||
&LocalPath::new(outside_root.to_string_lossy().into_owned()),
|
||||
ChatAttachmentImport {
|
||||
id: ChatAttachmentId::new("attach-2").unwrap(),
|
||||
filename: "outside-dir".to_owned(),
|
||||
mime: "application/octet-stream".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
created_at: 42,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, ChatAttachmentStoreError::Invalid(_)));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
Reference in New Issue
Block a user