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:
132
crates/domain/src/chat_attachment.rs
Normal file
132
crates/domain/src/chat_attachment.rs
Normal file
@ -0,0 +1,132 @@
|
||||
//! Agent/chat attachment domain model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, ProjectId, SessionId};
|
||||
|
||||
/// Stable chat attachment identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ChatAttachmentId(String);
|
||||
|
||||
impl ChatAttachmentId {
|
||||
/// Builds an attachment id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ChatAttachmentError::InvalidId`] when the id is empty or not filename-safe.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ChatAttachmentError> {
|
||||
let value = value.into();
|
||||
if value.is_empty()
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidId(value));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw id.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the attachment originally came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChatAttachmentSourceKind {
|
||||
/// User-selected local filesystem path.
|
||||
LocalFile,
|
||||
/// Clipboard paste, materialized by a driving adapter.
|
||||
Clipboard,
|
||||
/// Drag-and-drop, materialized by a driving adapter.
|
||||
DragDrop,
|
||||
/// Unknown or future source.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Durable metadata for one agent/chat attachment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatAttachment {
|
||||
/// Stable attachment id.
|
||||
pub id: ChatAttachmentId,
|
||||
/// Project that owns the managed attachment.
|
||||
pub project_id: ProjectId,
|
||||
/// Target agent for the chat flow.
|
||||
pub agent_id: AgentId,
|
||||
/// Structured/chat session that imported the file.
|
||||
pub session_id: SessionId,
|
||||
/// Original display filename.
|
||||
pub filename: String,
|
||||
/// MIME type.
|
||||
pub mime: String,
|
||||
/// Size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
/// Stored relative path under `.ideai/attachments`.
|
||||
pub storage_path: String,
|
||||
/// Absolute managed path readable by the target agent sandbox.
|
||||
pub readable_path: String,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl ChatAttachment {
|
||||
/// Validates persisted metadata.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ChatAttachmentError`] when an invariant is violated.
|
||||
pub fn validate(&self) -> Result<(), ChatAttachmentError> {
|
||||
if self.filename.trim().is_empty()
|
||||
|| self.filename.contains('/')
|
||||
|| self.filename.contains('\\')
|
||||
|| self.filename == "."
|
||||
|| self.filename == ".."
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidFilename(self.filename.clone()));
|
||||
}
|
||||
if self.mime.trim().is_empty() {
|
||||
return Err(ChatAttachmentError::InvalidMime(self.mime.clone()));
|
||||
}
|
||||
if self.storage_path.starts_with('/')
|
||||
|| self.storage_path.starts_with('\\')
|
||||
|| self.storage_path.contains("..")
|
||||
|| !self.storage_path.starts_with("agent-chat/")
|
||||
{
|
||||
return Err(ChatAttachmentError::InvalidStoragePath(
|
||||
self.storage_path.clone(),
|
||||
));
|
||||
}
|
||||
if self.readable_path.trim().is_empty() {
|
||||
return Err(ChatAttachmentError::InvalidReadablePath(
|
||||
self.readable_path.clone(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Attachment validation errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ChatAttachmentError {
|
||||
/// Invalid id.
|
||||
#[error("invalid chat attachment id: {0}")]
|
||||
InvalidId(String),
|
||||
/// Invalid filename.
|
||||
#[error("invalid chat attachment filename: {0}")]
|
||||
InvalidFilename(String),
|
||||
/// Invalid MIME.
|
||||
#[error("invalid chat attachment mime: {0}")]
|
||||
InvalidMime(String),
|
||||
/// Invalid relative storage path.
|
||||
#[error("invalid chat attachment storage path: {0}")]
|
||||
InvalidStoragePath(String),
|
||||
/// Invalid readable path.
|
||||
#[error("invalid chat attachment readable path: {0}")]
|
||||
InvalidReadablePath(String),
|
||||
}
|
||||
@ -33,6 +33,7 @@
|
||||
pub mod agent;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachment;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
pub mod device;
|
||||
@ -101,6 +102,10 @@ pub use background_task::{
|
||||
BACKGROUND_TASK_TEXT_MAX_BYTES,
|
||||
};
|
||||
|
||||
pub use chat_attachment::{
|
||||
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
||||
};
|
||||
|
||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy;
|
||||
use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
||||
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{
|
||||
@ -1066,6 +1067,57 @@ pub struct IssueAttachmentContent {
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Metadata supplied by a driving adapter when importing a chat attachment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChatAttachmentImport {
|
||||
/// Stable id allocated by the application layer.
|
||||
pub id: ChatAttachmentId,
|
||||
/// Original display filename.
|
||||
pub filename: String,
|
||||
/// MIME type.
|
||||
pub mime: String,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
/// Chat attachment store errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ChatAttachmentStoreError {
|
||||
/// The requested attachment does not exist.
|
||||
#[error("chat attachment not found")]
|
||||
NotFound,
|
||||
/// The attachment payload is invalid.
|
||||
#[error("chat attachment invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Store I/O or serialization failed.
|
||||
#[error("chat attachment store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped agent/chat attachments.
|
||||
#[async_trait]
|
||||
pub trait ChatAttachmentStore: Send + Sync {
|
||||
/// Copies a local source file into IdeA-managed durable attachment storage.
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
|
||||
|
||||
/// Lists attachments imported for a structured/chat session.
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
|
||||
}
|
||||
|
||||
/// Errors from the sprint store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SprintStoreError {
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ChatAttachment,
|
||||
ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind, ContextInjection, DomainError,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||
};
|
||||
@ -19,6 +20,23 @@ fn template_id() -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(7))
|
||||
}
|
||||
|
||||
fn chat_attachment() -> ChatAttachment {
|
||||
ChatAttachment {
|
||||
id: ChatAttachmentId::new("attach-1").unwrap(),
|
||||
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
agent_id: domain::AgentId::from_uuid(Uuid::from_u128(2)),
|
||||
session_id: domain::SessionId::from_uuid(Uuid::from_u128(3)),
|
||||
filename: "note.txt".to_owned(),
|
||||
mime: "text/plain".to_owned(),
|
||||
size_bytes: 12,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
storage_path: "agent-chat/session/attach-1-note.txt".to_owned(),
|
||||
readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-note.txt"
|
||||
.to_owned(),
|
||||
created_at: 123,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectPath
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -52,6 +70,26 @@ fn project_path_rejects_empty() {
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_metadata_accepts_agent_chat_storage_path() {
|
||||
assert!(chat_attachment().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_metadata_rejects_storage_path_outside_agent_chat_namespace() {
|
||||
let mut attachment = chat_attachment();
|
||||
attachment.storage_path = "tickets/1/file.txt".to_owned();
|
||||
|
||||
assert!(matches!(
|
||||
attachment.validate(),
|
||||
Err(ChatAttachmentError::InvalidStoragePath(_))
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project (also exercises the Clock/IdGenerator port fakes for determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user