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:
2026-08-13 13:34:33 +02:00
parent d7dfbcdfcb
commit fdca55ecd4
22 changed files with 2000 additions and 72 deletions

View File

@ -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 {

View File

@ -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,

View 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())
}