merge(attachments): intègre feature/154-attachments-pipeline — fondation pipeline durable d'attachments agent/chat (#154, QA verte)
This commit is contained in:
@ -7,7 +7,6 @@
|
|||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
@ -31,7 +30,7 @@ use application::{
|
|||||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, TICKET_ATTACHMENT_MAX_BYTES,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use backend::stream::OutputSink;
|
use backend::stream::OutputSink;
|
||||||
use domain::ports::ModelServerRuntime;
|
use domain::ports::ModelServerRuntime;
|
||||||
@ -45,34 +44,36 @@ use crate::dto::{
|
|||||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
||||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
||||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
||||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||||
|
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||||
|
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
WriteTerminalRequestDto,
|
WriteTerminalRequestDto,
|
||||||
@ -2159,6 +2160,7 @@ pub async fn agent_send(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
attachment_paths: Option<Vec<String>>,
|
attachment_paths: Option<Vec<String>>,
|
||||||
|
attachments: Option<Vec<ChatAttachmentInputDto>>,
|
||||||
on_reply: Channel<ReplyChunk>,
|
on_reply: Channel<ReplyChunk>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<(), ErrorDto> {
|
||||||
@ -2175,9 +2177,14 @@ pub async fn agent_send(
|
|||||||
// pump (if any) is superseded and stops delivering to its stale channel.
|
// pump (if any) is superseded and stops delivering to its stale channel.
|
||||||
let gen = state.chat_bridge.register(sid, on_reply);
|
let gen = state.chat_bridge.register(sid, on_reply);
|
||||||
|
|
||||||
let staged_attachments =
|
let imported_attachments = import_chat_attachments_for_session(
|
||||||
stage_chat_attachments(&state, &sid, attachment_paths.unwrap_or_default()).await?;
|
&state,
|
||||||
let prompt_for_model = prompt_with_staged_attachments(&prompt, &staged_attachments);
|
&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
|
// Retain the human submit in the same live scrollback as model chunks so
|
||||||
// `reattach_agent_chat` can repaint the whole in-flight conversation.
|
// `reattach_agent_chat` can repaint the whole in-flight conversation.
|
||||||
@ -2262,12 +2269,39 @@ pub async fn agent_send(
|
|||||||
Ok(())
|
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,
|
state: &AppState,
|
||||||
session_id: &domain::SessionId,
|
session_id: &domain::SessionId,
|
||||||
paths: Vec<String>,
|
legacy_paths: Vec<String>,
|
||||||
) -> Result<Vec<PathBuf>, ErrorDto> {
|
attachments: Vec<ChatAttachmentInputDto>,
|
||||||
if paths.is_empty() {
|
) -> Result<Vec<ChatAttachmentDto>, ErrorDto> {
|
||||||
|
if legacy_paths.is_empty() && attachments.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let (project_id, agent_id, _, _) = state
|
let (project_id, agent_id, _, _) = state
|
||||||
@ -2287,82 +2321,64 @@ async fn stage_chat_attachments(
|
|||||||
.find(|project| project.id == project_id)
|
.find(|project| project.id == project_id)
|
||||||
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
|
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
|
||||||
|
|
||||||
let stage_dir = Path::new(project.root.as_str())
|
let mut seen_paths = std::collections::HashSet::new();
|
||||||
.join(".ideai")
|
let mut items = Vec::with_capacity(legacy_paths.len() + attachments.len());
|
||||||
.join("run")
|
for path in legacy_paths {
|
||||||
.join(agent_id.to_string())
|
if seen_paths.insert(path.clone()) {
|
||||||
.join("attachments")
|
items.push(application::ImportChatAttachmentItem {
|
||||||
.join(session_id.to_string());
|
path,
|
||||||
if tokio::fs::metadata(&stage_dir).await.is_ok() {
|
mime: None,
|
||||||
tokio::fs::remove_dir_all(&stage_dir)
|
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
|
||||||
.await
|
});
|
||||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
}
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir_all(&stage_dir)
|
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 output = state
|
||||||
|
.import_chat_attachments
|
||||||
|
.execute(application::ImportChatAttachmentsInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
session_id: *session_id,
|
||||||
|
attachments: items,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
|
.map_err(ErrorDto::from)?;
|
||||||
|
Ok(output
|
||||||
let mut staged = Vec::with_capacity(paths.len());
|
.attachments
|
||||||
for (index, raw_path) in paths.iter().enumerate() {
|
.into_iter()
|
||||||
let source = PathBuf::from(raw_path);
|
.map(ChatAttachmentDto::from)
|
||||||
let meta = tokio::fs::metadata(&source)
|
.collect())
|
||||||
.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
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
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 {
|
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
|
||||||
let lowered = filename.to_ascii_lowercase();
|
if attachments.is_empty() {
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_with_staged_attachments(prompt: &str, staged: &[PathBuf]) -> String {
|
|
||||||
if staged.is_empty() {
|
|
||||||
return prompt.to_owned();
|
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(prompt);
|
||||||
out.push_str("\n\nPièces jointes copiées dans le run dir de cette session :\n");
|
out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n");
|
||||||
for path in staged {
|
for attachment in attachments {
|
||||||
out.push_str("- ");
|
out.push_str("- id: ");
|
||||||
out.push_str(&path.to_string_lossy());
|
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.push('\n');
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@ -341,6 +341,7 @@ pub fn run() {
|
|||||||
commands::launch_agent,
|
commands::launch_agent,
|
||||||
commands::change_agent_profile,
|
commands::change_agent_profile,
|
||||||
commands::agent_send,
|
commands::agent_send,
|
||||||
|
commands::import_chat_attachments,
|
||||||
commands::cancel_resume,
|
commands::cancel_resume,
|
||||||
commands::set_resume_at,
|
commands::set_resume_at,
|
||||||
commands::interrupt_agent,
|
commands::interrupt_agent,
|
||||||
|
|||||||
@ -6,11 +6,14 @@
|
|||||||
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
|
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
|
||||||
//! `cellKind` and PTY paths keep `"pty"`.
|
//! `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 application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::SessionId;
|
|
||||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||||
|
use domain::{ChatAttachmentSourceKind, SessionId};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -103,6 +106,84 @@ fn reply_chunk_rejects_snake_case_tag() {
|
|||||||
assert!(r.is_err(), "snake_case kind must not deserialise");
|
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)
|
// 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`.
|
//! with one error shape when building its `ErrorDTO`.
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
AgentSessionError, ChatAttachmentStoreError, EmbedderError, FsError, GitError, MemoryError,
|
||||||
ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError,
|
ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError,
|
||||||
|
StoreError,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, NodeId};
|
use domain::{AgentId, NodeId};
|
||||||
use domain::{IssueStoreError, SprintStoreError};
|
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 {
|
impl From<MemoryError> for AppError {
|
||||||
fn from(e: MemoryError) -> Self {
|
fn from(e: MemoryError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
|
pub mod chat_attachments;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
@ -70,6 +71,10 @@ pub use background::{
|
|||||||
RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput,
|
RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput,
|
||||||
SpawnBackgroundCommandOutput,
|
SpawnBackgroundCommandOutput,
|
||||||
};
|
};
|
||||||
|
pub use chat_attachments::{
|
||||||
|
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
|
||||||
|
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||||
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
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 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
|
/// One incremental chunk of a structured agent reply, streamed over the chat
|
||||||
/// session's adapter-owned channel. The serialised wire twin of a
|
/// session's adapter-owned channel. The serialised wire twin of a
|
||||||
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
|
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
|
||||||
|
|||||||
@ -26,45 +26,46 @@ use application::{
|
|||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
||||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles,
|
||||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
|
||||||
MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal,
|
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||||
PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||||
ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
|
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||||
ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
|
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||||
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
|
||||||
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
|
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||||
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
||||||
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||||
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||||
@ -89,19 +90,20 @@ use infrastructure::{
|
|||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||||
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
||||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||||
|
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
@ -1014,6 +1016,8 @@ pub struct BackendCore {
|
|||||||
pub read_issue_attachment: Arc<ReadIssueAttachment>,
|
pub read_issue_attachment: Arc<ReadIssueAttachment>,
|
||||||
/// Mark a ticket attachment as summarized in the carnet.
|
/// Mark a ticket attachment as summarized in the carnet.
|
||||||
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
||||||
|
/// Import durable agent/chat attachments.
|
||||||
|
pub import_chat_attachments: Arc<ImportChatAttachments>,
|
||||||
/// Link two public tickets.
|
/// Link two public tickets.
|
||||||
pub link_issues: Arc<LinkIssues>,
|
pub link_issues: Arc<LinkIssues>,
|
||||||
/// Unlink public tickets.
|
/// Unlink public tickets.
|
||||||
@ -1696,6 +1700,9 @@ impl BackendCore {
|
|||||||
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
|
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
|
||||||
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
|
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
|
||||||
let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
|
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(
|
let create_issue = Arc::new(CreateIssue::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
Arc::clone(&issue_allocator_port),
|
Arc::clone(&issue_allocator_port),
|
||||||
@ -1749,6 +1756,11 @@ impl BackendCore {
|
|||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
let 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(
|
let link_issues = Arc::new(LinkIssues::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
@ -2978,6 +2990,7 @@ impl BackendCore {
|
|||||||
add_issue_attachment,
|
add_issue_attachment,
|
||||||
read_issue_attachment,
|
read_issue_attachment,
|
||||||
mark_issue_attachment_summarized,
|
mark_issue_attachment_summarized,
|
||||||
|
import_chat_attachments,
|
||||||
link_issues,
|
link_issues,
|
||||||
unlink_issues,
|
unlink_issues,
|
||||||
assign_issue_agent,
|
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;
|
||||||
pub mod agent_tool_policy;
|
pub mod agent_tool_policy;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
|
pub mod chat_attachment;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
@ -101,6 +102,10 @@ pub use background_task::{
|
|||||||
BACKGROUND_TASK_TEXT_MAX_BYTES,
|
BACKGROUND_TASK_TEXT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use chat_attachment::{
|
||||||
|
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
||||||
|
};
|
||||||
|
|
||||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||||
|
|
||||||
pub use template::{AgentTemplate, TemplateVersion};
|
pub use template::{AgentTemplate, TemplateVersion};
|
||||||
|
|||||||
@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy;
|
|||||||
use crate::background_task::{
|
use crate::background_task::{
|
||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
|
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
||||||
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
@ -1066,6 +1067,57 @@ pub struct IssueAttachmentContent {
|
|||||||
pub bytes: Vec<u8>,
|
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.
|
/// Errors from the sprint store.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum SprintStoreError {
|
pub enum SprintStoreError {
|
||||||
|
|||||||
@ -4,7 +4,8 @@
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use domain::{
|
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,
|
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
|
||||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||||
};
|
};
|
||||||
@ -19,6 +20,23 @@ fn template_id() -> TemplateId {
|
|||||||
TemplateId::from_uuid(Uuid::from_u128(7))
|
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
|
// ProjectPath
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -52,6 +70,26 @@ fn project_path_rejects_empty() {
|
|||||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
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)
|
// 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 assistant;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
|
pub mod chat_attachments;
|
||||||
pub mod clock;
|
pub mod clock;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
@ -53,6 +54,7 @@ pub use background_task::{
|
|||||||
BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail,
|
BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail,
|
||||||
CommandBackgroundRunner,
|
CommandBackgroundRunner,
|
||||||
};
|
};
|
||||||
|
pub use chat_attachments::FsChatAttachmentStore;
|
||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
pub use conversation_log::{
|
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