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:
241
crates/infrastructure/src/managed_temp.rs
Normal file
241
crates/infrastructure/src/managed_temp.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user