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)
|
||||
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
|
||||
.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
|
||||
))));
|
||||
}
|
||||
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)
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(output
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(ChatAttachmentDto::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user