feat(infra): tmp partagé inter-projets géré par IdeA (handles opaques, leases, GC, projection sandbox) — #189 (QA verte)
- Domaine: port ManagedTempStore + modèle (handle opaque, kind Directory/File, état Active/Released, allocation/lease, expiry). - Application: use cases Allocate/Resolve/Release/GcManagedTemp, horodatés via Clock. - Infrastructure: impl FS FsManagedTempStore sous app_data_dir/managed-tmp (root global inter-projets, métadonnées par handle, GC des leases expirés/libérés). - Exposition MCP contrôlée (ManagedTempToolProvider, catalogue) + câblage sandbox: les agents résolvent un handle au lieu d'inventer un path libre. - Tests: application/infrastructure managed_temp.
This commit is contained in:
@ -10,7 +10,7 @@ use domain::ports::{
|
||||
ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{AgentId, ManagedTempError, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
@ -178,6 +178,16 @@ impl From<ChatAttachmentStoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ManagedTempError> for AppError {
|
||||
fn from(e: ManagedTempError) -> Self {
|
||||
match e {
|
||||
ManagedTempError::NotFound => Self::NotFound("managed temp".to_owned()),
|
||||
ManagedTempError::Invalid(message) => Self::Invalid(message),
|
||||
ManagedTempError::Store(message) => Self::Store(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
|
||||
@ -24,6 +24,7 @@ pub mod git;
|
||||
pub mod health;
|
||||
pub mod issues;
|
||||
pub mod layout;
|
||||
pub mod managed_temp;
|
||||
pub mod mcp_tool_permissions;
|
||||
pub mod memory;
|
||||
pub mod model_server;
|
||||
@ -130,6 +131,12 @@ pub use layout::{
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use managed_temp::{
|
||||
AllocateManagedTemp, AllocateManagedTempInput, AllocateManagedTempOutput, GcManagedTemp,
|
||||
GcManagedTempOutput, ReleaseManagedTemp, ReleaseManagedTempInput, ReleaseManagedTempOutput,
|
||||
ResolveManagedTemp, ResolveManagedTempInput, ResolveManagedTempOutput,
|
||||
MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
|
||||
};
|
||||
pub use mcp_tool_permissions::{
|
||||
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
|
||||
ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions,
|
||||
|
||||
209
crates/application/src/managed_temp.rs
Normal file
209
crates/application/src/managed_temp.rs
Normal file
@ -0,0 +1,209 @@
|
||||
//! Managed temporary storage use cases.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Clock, IdGenerator, ManagedTempStore};
|
||||
use domain::{
|
||||
ManagedTempAllocation, ManagedTempGcReport, ManagedTempHandle, ManagedTempKind,
|
||||
ManagedTempLease, ProjectId,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Default lease TTL for managed temp allocations: 24 hours.
|
||||
pub const MANAGED_TEMP_DEFAULT_TTL_MS: u64 = 24 * 60 * 60 * 1_000;
|
||||
/// Maximum purpose length retained in metadata.
|
||||
pub const MANAGED_TEMP_PURPOSE_MAX_CHARS: usize = 120;
|
||||
|
||||
/// Input for [`AllocateManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AllocateManagedTempInput {
|
||||
/// Optional project that requested this allocation.
|
||||
pub owner_project_id: Option<ProjectId>,
|
||||
/// Physical allocation kind.
|
||||
pub kind: ManagedTempKind,
|
||||
/// Short human/debug purpose.
|
||||
pub purpose: String,
|
||||
/// Optional TTL override in milliseconds. `None` uses the default.
|
||||
pub ttl_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Output for [`AllocateManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AllocateManagedTempOutput {
|
||||
/// Allocated lease.
|
||||
pub lease: ManagedTempLease,
|
||||
}
|
||||
|
||||
/// Allocates an IdeA-managed temporary file/directory.
|
||||
pub struct AllocateManagedTemp {
|
||||
store: Arc<dyn ManagedTempStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl AllocateManagedTemp {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ManagedTempStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { store, ids, clock }
|
||||
}
|
||||
|
||||
/// Executes allocation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: AllocateManagedTempInput,
|
||||
) -> Result<AllocateManagedTempOutput, AppError> {
|
||||
let purpose = bounded_purpose(input.purpose)?;
|
||||
let now = now_ms(&self.clock);
|
||||
let ttl_ms = input.ttl_ms.unwrap_or(MANAGED_TEMP_DEFAULT_TTL_MS);
|
||||
if ttl_ms == 0 {
|
||||
return Err(AppError::Invalid(
|
||||
"managed temp ttl must be positive".to_owned(),
|
||||
));
|
||||
}
|
||||
let expires_at_ms = now.checked_add(ttl_ms).ok_or_else(|| {
|
||||
AppError::Invalid("managed temp ttl exceeds timestamp range".to_owned())
|
||||
})?;
|
||||
let handle = ManagedTempHandle::new(self.ids.new_uuid().to_string())
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let lease = self
|
||||
.store
|
||||
.allocate(ManagedTempAllocation {
|
||||
handle,
|
||||
kind: input.kind,
|
||||
owner_project_id: input.owner_project_id,
|
||||
purpose,
|
||||
created_at_ms: now,
|
||||
expires_at_ms: Some(expires_at_ms),
|
||||
})
|
||||
.await?;
|
||||
Ok(AllocateManagedTempOutput { lease })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResolveManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveManagedTempInput {
|
||||
/// Opaque handle to resolve.
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// Output for [`ResolveManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveManagedTempOutput {
|
||||
/// Resolved active lease.
|
||||
pub lease: ManagedTempLease,
|
||||
}
|
||||
|
||||
/// Resolves a managed temp handle.
|
||||
pub struct ResolveManagedTemp {
|
||||
store: Arc<dyn ManagedTempStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ResolveManagedTemp {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ManagedTempStore>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self { store, clock }
|
||||
}
|
||||
|
||||
/// Executes resolution.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveManagedTempInput,
|
||||
) -> Result<ResolveManagedTempOutput, AppError> {
|
||||
let handle = ManagedTempHandle::new(input.handle)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let lease = self.store.resolve(&handle, now_ms(&self.clock)).await?;
|
||||
Ok(ResolveManagedTempOutput { lease })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ReleaseManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReleaseManagedTempInput {
|
||||
/// Opaque handle to release.
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// Output for [`ReleaseManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReleaseManagedTempOutput {
|
||||
/// Released lease.
|
||||
pub lease: ManagedTempLease,
|
||||
}
|
||||
|
||||
/// Releases a managed temp handle.
|
||||
pub struct ReleaseManagedTemp {
|
||||
store: Arc<dyn ManagedTempStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ReleaseManagedTemp {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ManagedTempStore>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self { store, clock }
|
||||
}
|
||||
|
||||
/// Executes release.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReleaseManagedTempInput,
|
||||
) -> Result<ReleaseManagedTempOutput, AppError> {
|
||||
let handle = ManagedTempHandle::new(input.handle)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let lease = self.store.release(&handle, now_ms(&self.clock)).await?;
|
||||
Ok(ReleaseManagedTempOutput { lease })
|
||||
}
|
||||
}
|
||||
|
||||
/// Output for [`GcManagedTemp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GcManagedTempOutput {
|
||||
/// GC report.
|
||||
pub report: ManagedTempGcReport,
|
||||
}
|
||||
|
||||
/// Runs managed temp garbage collection.
|
||||
pub struct GcManagedTemp {
|
||||
store: Arc<dyn ManagedTempStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl GcManagedTemp {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ManagedTempStore>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self { store, clock }
|
||||
}
|
||||
|
||||
/// Executes GC.
|
||||
pub async fn execute(&self) -> Result<GcManagedTempOutput, AppError> {
|
||||
let report = self.store.gc(now_ms(&self.clock)).await?;
|
||||
Ok(GcManagedTempOutput { report })
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms(clock: &Arc<dyn Clock>) -> u64 {
|
||||
u64::try_from(clock.now_millis()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn bounded_purpose(raw: String) -> Result<String, AppError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"managed temp purpose must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(trimmed
|
||||
.chars()
|
||||
.take(MANAGED_TEMP_PURPOSE_MAX_CHARS)
|
||||
.collect())
|
||||
}
|
||||
209
crates/application/tests/managed_temp.rs
Normal file
209
crates/application/tests/managed_temp.rs
Normal file
@ -0,0 +1,209 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{Clock, IdGenerator, LocalPath, ManagedTempStore};
|
||||
use domain::{
|
||||
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
|
||||
ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AllocateManagedTemp, AllocateManagedTempInput, AppError, ResolveManagedTemp,
|
||||
ResolveManagedTempInput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
|
||||
};
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
10_000
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedIds;
|
||||
|
||||
impl IdGenerator for FixedIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
Uuid::from_u128(0x1234)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeManagedTempStore {
|
||||
allocations: Mutex<Vec<ManagedTempAllocation>>,
|
||||
leases: Mutex<HashMap<ManagedTempHandle, ManagedTempLease>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempStore for FakeManagedTempStore {
|
||||
async fn allocate(
|
||||
&self,
|
||||
allocation: ManagedTempAllocation,
|
||||
) -> Result<ManagedTempLease, ManagedTempError> {
|
||||
let lease = ManagedTempLease {
|
||||
handle: allocation.handle.clone(),
|
||||
kind: allocation.kind,
|
||||
owner_project_id: allocation.owner_project_id,
|
||||
purpose: allocation.purpose.clone(),
|
||||
path: LocalPath::new(format!("/managed/{}", allocation.handle.as_str())),
|
||||
state: ManagedTempState::Active,
|
||||
created_at_ms: allocation.created_at_ms,
|
||||
last_access_at_ms: allocation.created_at_ms,
|
||||
expires_at_ms: allocation.expires_at_ms,
|
||||
released_at_ms: None,
|
||||
};
|
||||
self.allocations.lock().unwrap().push(allocation);
|
||||
self.leases
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(lease.handle.clone(), lease.clone());
|
||||
Ok(lease)
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
handle: &ManagedTempHandle,
|
||||
_now_ms: u64,
|
||||
) -> Result<ManagedTempLease, ManagedTempError> {
|
||||
self.leases
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(handle)
|
||||
.cloned()
|
||||
.ok_or(ManagedTempError::NotFound)
|
||||
}
|
||||
|
||||
async fn release(
|
||||
&self,
|
||||
handle: &ManagedTempHandle,
|
||||
now_ms: u64,
|
||||
) -> Result<ManagedTempLease, ManagedTempError> {
|
||||
let mut leases = self.leases.lock().unwrap();
|
||||
let lease = leases.get_mut(handle).ok_or(ManagedTempError::NotFound)?;
|
||||
lease.state = ManagedTempState::Released;
|
||||
lease.released_at_ms = Some(now_ms);
|
||||
Ok(lease.clone())
|
||||
}
|
||||
|
||||
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
|
||||
Ok(ManagedTempGcReport { deleted: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allocate_uses_generated_opaque_handle_and_default_ttl() {
|
||||
let store = Arc::new(FakeManagedTempStore::default());
|
||||
let use_case = AllocateManagedTemp::new(
|
||||
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let owner = ProjectId::from_uuid(Uuid::from_u128(7));
|
||||
|
||||
let output = use_case
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: Some(owner),
|
||||
kind: ManagedTempKind::Directory,
|
||||
purpose: " shared scratch ".to_owned(),
|
||||
ttl_ms: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.lease.handle.as_str(),
|
||||
"00000000-0000-0000-0000-000000001234"
|
||||
);
|
||||
assert_eq!(
|
||||
output.lease.path.as_str(),
|
||||
"/managed/00000000-0000-0000-0000-000000001234"
|
||||
);
|
||||
|
||||
let allocations = store.allocations.lock().unwrap();
|
||||
assert_eq!(allocations.len(), 1);
|
||||
assert_eq!(allocations[0].owner_project_id, Some(owner));
|
||||
assert_eq!(allocations[0].purpose, "shared scratch");
|
||||
assert_eq!(allocations[0].created_at_ms, 10_000);
|
||||
assert_eq!(
|
||||
allocations[0].expires_at_ms,
|
||||
Some(10_000 + MANAGED_TEMP_DEFAULT_TTL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allocate_rejects_empty_or_zero_ttl_and_bounds_purpose_metadata() {
|
||||
let store = Arc::new(FakeManagedTempStore::default());
|
||||
let use_case = AllocateManagedTemp::new(
|
||||
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let empty = use_case
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: None,
|
||||
kind: ManagedTempKind::File,
|
||||
purpose: " ".to_owned(),
|
||||
ttl_ms: Some(1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(empty, AppError::Invalid(_)));
|
||||
|
||||
let zero_ttl = use_case
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: None,
|
||||
kind: ManagedTempKind::File,
|
||||
purpose: "scratch".to_owned(),
|
||||
ttl_ms: Some(0),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(zero_ttl, AppError::Invalid(_)));
|
||||
|
||||
let overflow_ttl = use_case
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: None,
|
||||
kind: ManagedTempKind::File,
|
||||
purpose: "scratch".to_owned(),
|
||||
ttl_ms: Some(u64::MAX),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(overflow_ttl, AppError::Invalid(_)));
|
||||
|
||||
use_case
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: None,
|
||||
kind: ManagedTempKind::File,
|
||||
purpose: "x".repeat(MANAGED_TEMP_PURPOSE_MAX_CHARS + 10),
|
||||
ttl_ms: Some(5),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let allocations = store.allocations.lock().unwrap();
|
||||
assert_eq!(
|
||||
allocations[0].purpose.chars().count(),
|
||||
MANAGED_TEMP_PURPOSE_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_rejects_path_like_handle_before_store_call() {
|
||||
let store = Arc::new(FakeManagedTempStore::default());
|
||||
let use_case = ResolveManagedTemp::new(
|
||||
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = use_case
|
||||
.execute(ResolveManagedTempInput {
|
||||
handle: "../escape".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, AppError::Invalid(_)));
|
||||
}
|
||||
Reference in New Issue
Block a user