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

@ -0,0 +1,241 @@
//! Filesystem-backed managed temporary store.
//!
//! Allocations live under `<app_data_dir>/managed-tmp/<opaque-handle>/`.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ports::{LocalPath, ManagedTempStore};
use domain::{
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId,
};
const MANAGED_TMP_DIR: &str = "managed-tmp";
const METADATA_FILE: &str = "metadata.json";
const DATA_NAME: &str = "data";
/// Filesystem implementation for global IdeA-managed temporary allocations.
#[derive(Debug, Clone)]
pub struct FsManagedTempStore {
root: PathBuf,
}
impl FsManagedTempStore {
/// Builds the store under `<app_data_dir>/managed-tmp`.
#[must_use]
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
Self {
root: app_data_dir.into().join(MANAGED_TMP_DIR),
}
}
/// Returns the global managed temp root.
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredLease {
handle: ManagedTempHandle,
kind: ManagedTempKind,
owner_project_id: Option<ProjectId>,
purpose: String,
state: ManagedTempState,
created_at_ms: u64,
last_access_at_ms: u64,
expires_at_ms: Option<u64>,
released_at_ms: Option<u64>,
}
impl StoredLease {
fn from_allocation(allocation: ManagedTempAllocation) -> Self {
Self {
handle: allocation.handle,
kind: allocation.kind,
owner_project_id: allocation.owner_project_id,
purpose: allocation.purpose,
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,
}
}
fn is_expired(&self, now_ms: u64) -> bool {
self.expires_at_ms.is_some_and(|expires| expires <= now_ms)
}
fn to_lease(&self, path: PathBuf) -> ManagedTempLease {
ManagedTempLease {
handle: self.handle.clone(),
kind: self.kind,
owner_project_id: self.owner_project_id,
purpose: self.purpose.clone(),
path: LocalPath::new(path.to_string_lossy().into_owned()),
state: self.state,
created_at_ms: self.created_at_ms,
last_access_at_ms: self.last_access_at_ms,
expires_at_ms: self.expires_at_ms,
released_at_ms: self.released_at_ms,
}
}
}
fn io_error(err: std::io::Error) -> ManagedTempError {
ManagedTempError::Store(err.to_string())
}
fn serde_error(err: serde_json::Error) -> ManagedTempError {
ManagedTempError::Store(err.to_string())
}
fn allocation_dir(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
root.join(handle.as_str())
}
fn metadata_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
allocation_dir(root, handle).join(METADATA_FILE)
}
fn data_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
allocation_dir(root, handle).join(DATA_NAME)
}
fn ensure_under_root(root: &Path, candidate: &Path) -> Result<(), ManagedTempError> {
if candidate.starts_with(root) {
Ok(())
} else {
Err(ManagedTempError::Invalid(
"managed temp path escaped root".to_owned(),
))
}
}
async fn read_stored(
root: &Path,
handle: &ManagedTempHandle,
) -> Result<StoredLease, ManagedTempError> {
let path = metadata_path(root, handle);
let bytes = tokio::fs::read(&path).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ManagedTempError::NotFound
} else {
io_error(err)
}
})?;
let stored: StoredLease = serde_json::from_slice(&bytes).map_err(serde_error)?;
if stored.handle != *handle {
return Err(ManagedTempError::Invalid(
"managed temp metadata handle mismatch".to_owned(),
));
}
Ok(stored)
}
async fn write_stored(root: &Path, stored: &StoredLease) -> Result<(), ManagedTempError> {
let path = metadata_path(root, &stored.handle);
let bytes = serde_json::to_vec_pretty(stored).map_err(serde_error)?;
tokio::fs::write(path, bytes).await.map_err(io_error)
}
#[async_trait]
impl ManagedTempStore for FsManagedTempStore {
async fn allocate(
&self,
allocation: ManagedTempAllocation,
) -> Result<ManagedTempLease, ManagedTempError> {
let handle = allocation.handle.clone();
let dir = allocation_dir(&self.root, &handle);
let data = data_path(&self.root, &handle);
ensure_under_root(&self.root, &dir)?;
ensure_under_root(&self.root, &data)?;
tokio::fs::create_dir_all(&self.root)
.await
.map_err(io_error)?;
tokio::fs::create_dir(&dir).await.map_err(io_error)?;
match allocation.kind {
ManagedTempKind::Directory => tokio::fs::create_dir(&data).await.map_err(io_error)?,
ManagedTempKind::File => {
tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&data)
.await
.map_err(io_error)?;
}
}
let stored = StoredLease::from_allocation(allocation);
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data))
}
async fn resolve(
&self,
handle: &ManagedTempHandle,
now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
let mut stored = read_stored(&self.root, handle).await?;
if stored.state != ManagedTempState::Active || stored.is_expired(now_ms) {
return Err(ManagedTempError::NotFound);
}
let data = data_path(&self.root, handle);
ensure_under_root(&self.root, &data)?;
if tokio::fs::metadata(&data).await.is_err() {
return Err(ManagedTempError::NotFound);
}
stored.last_access_at_ms = now_ms;
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data))
}
async fn release(
&self,
handle: &ManagedTempHandle,
now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
let mut stored = read_stored(&self.root, handle).await?;
stored.state = ManagedTempState::Released;
stored.released_at_ms = Some(now_ms);
stored.last_access_at_ms = now_ms;
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data_path(&self.root, handle)))
}
async fn gc(&self, now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
let mut deleted = 0usize;
let mut entries = match tokio::fs::read_dir(&self.root).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(ManagedTempGcReport { deleted: 0 })
}
Err(err) => return Err(io_error(err)),
};
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
let file_type = entry.file_type().await.map_err(io_error)?;
if !file_type.is_dir() {
continue;
}
let raw_handle = entry.file_name().to_string_lossy().into_owned();
let Ok(handle) = ManagedTempHandle::new(raw_handle) else {
continue;
};
let Ok(stored) = read_stored(&self.root, &handle).await else {
continue;
};
if stored.state == ManagedTempState::Released || stored.is_expired(now_ms) {
tokio::fs::remove_dir_all(entry.path())
.await
.map_err(io_error)?;
deleted += 1;
}
}
Ok(ManagedTempGcReport { deleted })
}
}