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:
@ -29,6 +29,7 @@ pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod lock;
|
||||
pub mod mailbox;
|
||||
pub mod managed_temp;
|
||||
pub mod model_catalogue;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
@ -75,6 +76,7 @@ pub use inspector::{
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use lock::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use managed_temp::FsManagedTempStore;
|
||||
pub use model_catalogue::{
|
||||
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
||||
};
|
||||
@ -83,8 +85,9 @@ pub use model_server::{
|
||||
LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider,
|
||||
TicketToolError, TicketToolProvider, ToolPolicyRegistry,
|
||||
ManagedTempToolError, ManagedTempToolProvider, McpServer, MemoryTransport, StdioTransport,
|
||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
122
crates/infrastructure/src/orchestrator/mcp/managed_temp.rs
Normal file
122
crates/infrastructure/src/orchestrator/mcp/managed_temp.rs
Normal file
@ -0,0 +1,122 @@
|
||||
//! Public MCP managed temporary storage tools.
|
||||
//!
|
||||
//! The MCP surface exposes IdeA-managed temporary allocations without accepting
|
||||
//! caller-supplied filesystem paths. A driving adapter injects the provider that
|
||||
//! maps these calls to the application use cases.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::Project;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::tools::ToolDef;
|
||||
|
||||
/// Error returned by an MCP managed-temp tool provider.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{code}: {message}")]
|
||||
pub struct ManagedTempToolError {
|
||||
/// Stable machine-readable code.
|
||||
pub code: &'static str,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ManagedTempToolError {
|
||||
/// Builds a typed managed-temp tool error.
|
||||
#[must_use]
|
||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the error as a camelCase JSON object.
|
||||
#[must_use]
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({ "code": self.code, "message": self.message })
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider injected by the composition root to execute public managed-temp tools.
|
||||
#[async_trait]
|
||||
pub trait ManagedTempToolProvider: Send + Sync {
|
||||
/// Executes one public managed-temp tool for `project`.
|
||||
async fn handle_managed_temp_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, ManagedTempToolError>;
|
||||
}
|
||||
|
||||
/// Returns true when `name` is one of the public managed-temp tools.
|
||||
#[must_use]
|
||||
pub fn is_managed_temp_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"idea_managed_temp_allocate"
|
||||
| "idea_managed_temp_resolve"
|
||||
| "idea_managed_temp_release"
|
||||
| "idea_managed_temp_gc"
|
||||
)
|
||||
}
|
||||
|
||||
/// Public managed-temp tool definitions advertised by `tools/list`.
|
||||
#[must_use]
|
||||
pub fn catalogue() -> Vec<ToolDef> {
|
||||
let handle = json!({
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{1,128}$",
|
||||
"description": "Opaque managed temporary handle returned by allocate."
|
||||
});
|
||||
|
||||
vec![
|
||||
ToolDef {
|
||||
name: "idea_managed_temp_allocate",
|
||||
description: "Allocate an IdeA-managed temporary file or directory and return its \
|
||||
opaque handle plus metadata. The caller cannot provide a filesystem \
|
||||
path.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["directory", "file"] },
|
||||
"purpose": { "type": "string", "description": "Short human/debug purpose." },
|
||||
"ttlMs": { "type": "integer", "minimum": 1, "description": "Optional lease TTL in milliseconds." }
|
||||
},
|
||||
"required": ["kind", "purpose"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_managed_temp_resolve",
|
||||
description: "Resolve an active IdeA-managed temporary handle. Returns the \
|
||||
IdeA-controlled local path for the allocation data.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "handle": handle.clone() },
|
||||
"required": ["handle"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_managed_temp_release",
|
||||
description: "Release an IdeA-managed temporary handle, making it eligible for GC.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "handle": handle },
|
||||
"required": ["handle"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_managed_temp_gc",
|
||||
description: "Garbage-collect released or expired IdeA-managed temporary allocations.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -29,6 +29,7 @@
|
||||
//! child process).
|
||||
|
||||
pub mod jsonrpc;
|
||||
pub mod managed_temp;
|
||||
pub mod policy;
|
||||
pub mod server;
|
||||
pub mod templates;
|
||||
@ -39,6 +40,7 @@ pub mod transport;
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
|
||||
pub use policy::ToolPolicyRegistry;
|
||||
pub use server::McpServer;
|
||||
pub use templates::{TemplateToolError, TemplateToolProvider};
|
||||
|
||||
@ -34,6 +34,7 @@ use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::managed_temp::ManagedTempToolProvider;
|
||||
use super::policy::ToolPolicyRegistry;
|
||||
use super::templates::TemplateToolProvider;
|
||||
use super::tickets::TicketToolProvider;
|
||||
@ -80,6 +81,9 @@ pub struct McpServer {
|
||||
/// Optional public template provider. The MCP surface says `template`; the
|
||||
/// provider maps those calls to application/domain `Template` use cases.
|
||||
template_tools: Option<Arc<dyn TemplateToolProvider>>,
|
||||
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
|
||||
/// the provider maps those calls to application managed-temp use cases.
|
||||
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||
@ -101,6 +105,7 @@ impl McpServer {
|
||||
ready_sink: None,
|
||||
ticket_tools: None,
|
||||
template_tools: None,
|
||||
managed_temp_tools: None,
|
||||
tool_policies: None,
|
||||
mcp_tool_permissions: None,
|
||||
}
|
||||
@ -141,6 +146,16 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the public managed-temp tool provider.
|
||||
#[must_use]
|
||||
pub fn with_managed_temp_tools(
|
||||
mut self,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
) -> Self {
|
||||
self.managed_temp_tools = Some(managed_temp_tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
@ -176,6 +191,7 @@ impl McpServer {
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
template_tools: self.template_tools.clone(),
|
||||
managed_temp_tools: self.managed_temp_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||
}
|
||||
@ -543,6 +559,44 @@ impl McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
if tools::is_managed_temp_tool(&name) {
|
||||
let result = match &self.managed_temp_tools {
|
||||
Some(provider) => {
|
||||
provider
|
||||
.handle_managed_temp_tool(&self.project, &self.requester, &name, arguments)
|
||||
.await
|
||||
}
|
||||
None => Err(super::managed_temp::ManagedTempToolError::new(
|
||||
"notConfigured",
|
||||
"managed temp tools are not configured",
|
||||
)),
|
||||
};
|
||||
self.publish_processed(&name, result.is_ok());
|
||||
return match result {
|
||||
Ok(value) => {
|
||||
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
Err(err) => {
|
||||
let text =
|
||||
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, true))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
|
||||
@ -57,6 +57,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_sprint_list",
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
"idea_managed_temp_resolve",
|
||||
];
|
||||
|
||||
/// Canonical write/action MCP tools denied by default.
|
||||
@ -87,6 +88,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_template_create",
|
||||
"idea_template_update",
|
||||
"idea_template_delete",
|
||||
"idea_managed_temp_allocate",
|
||||
"idea_managed_temp_release",
|
||||
"idea_managed_temp_gc",
|
||||
];
|
||||
|
||||
/// All MCP tool names that have an explicit access classification.
|
||||
@ -142,6 +146,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
| "idea_workstate_read"
|
||||
) || is_ticket_tool(tool)
|
||||
|| is_template_tool(tool)
|
||||
|| is_managed_temp_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public ticket MCP tool.
|
||||
@ -156,6 +161,12 @@ pub fn is_template_tool(tool: &str) -> bool {
|
||||
super::templates::is_template_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public managed-temp MCP tool.
|
||||
#[must_use]
|
||||
pub fn is_managed_temp_tool(tool: &str) -> bool {
|
||||
super::managed_temp::is_managed_temp_tool(tool)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
///
|
||||
/// Exactly the tools whose mapping target already exists as an
|
||||
@ -415,6 +426,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
];
|
||||
tools.extend(super::tickets::catalogue());
|
||||
tools.extend(super::templates::catalogue());
|
||||
tools.extend(super::managed_temp::catalogue());
|
||||
tools
|
||||
}
|
||||
|
||||
@ -1090,4 +1102,37 @@ mod tests {
|
||||
"status enum must match WorkStatus labels exactly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_advertises_managed_temp_tools_with_expected_access() {
|
||||
let cat = catalogue();
|
||||
for tool in [
|
||||
"idea_managed_temp_allocate",
|
||||
"idea_managed_temp_resolve",
|
||||
"idea_managed_temp_release",
|
||||
"idea_managed_temp_gc",
|
||||
] {
|
||||
assert!(
|
||||
cat.iter().any(|entry| entry.name == tool),
|
||||
"catalogue must contain {tool}"
|
||||
);
|
||||
assert!(tool_returns_reply(tool), "{tool} returns JSON inline");
|
||||
}
|
||||
assert_eq!(
|
||||
tool_access("idea_managed_temp_resolve"),
|
||||
Some(McpToolAccess::Read)
|
||||
);
|
||||
assert_eq!(
|
||||
tool_access("idea_managed_temp_allocate"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert_eq!(
|
||||
tool_access("idea_managed_temp_release"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert_eq!(
|
||||
tool_access("idea_managed_temp_gc"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
175
crates/infrastructure/tests/managed_temp.rs
Normal file
175
crates/infrastructure/tests/managed_temp.rs
Normal file
@ -0,0 +1,175 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use domain::ports::ManagedTempStore;
|
||||
use domain::{
|
||||
ManagedTempAllocation, ManagedTempError, ManagedTempHandle, ManagedTempKind, ManagedTempState,
|
||||
ProjectId,
|
||||
};
|
||||
use infrastructure::FsManagedTempStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_app_data(tag: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"idea-managed-temp-{tag}-{}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn handle(value: &str) -> ManagedTempHandle {
|
||||
ManagedTempHandle::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn allocation(
|
||||
handle: ManagedTempHandle,
|
||||
kind: ManagedTempKind,
|
||||
purpose: &str,
|
||||
created_at_ms: u64,
|
||||
expires_at_ms: Option<u64>,
|
||||
) -> ManagedTempAllocation {
|
||||
ManagedTempAllocation {
|
||||
handle,
|
||||
kind,
|
||||
owner_project_id: Some(ProjectId::from_uuid(Uuid::from_u128(42))),
|
||||
purpose: purpose.to_owned(),
|
||||
created_at_ms,
|
||||
expires_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_under(path: &Path, root: &Path) {
|
||||
assert!(
|
||||
path.starts_with(root),
|
||||
"{} should be under {}",
|
||||
path.display(),
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allocate_resolve_release_and_gc_directory() {
|
||||
let app_data = temp_app_data("dir");
|
||||
let store = FsManagedTempStore::new(&app_data);
|
||||
let temp_root = std::env::temp_dir();
|
||||
assert_eq!(store.root(), app_data.join("managed-tmp"));
|
||||
assert_ne!(store.root(), temp_root.as_path());
|
||||
|
||||
let lease = store
|
||||
.allocate(allocation(
|
||||
handle("alloc_dir_1"),
|
||||
ManagedTempKind::Directory,
|
||||
"scratch",
|
||||
1_000,
|
||||
Some(2_000),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let path = PathBuf::from(lease.path.as_str());
|
||||
assert_eq!(lease.state, ManagedTempState::Active);
|
||||
assert_eq!(lease.last_access_at_ms, 1_000);
|
||||
assert_under(&path, store.root());
|
||||
assert!(tokio::fs::metadata(&path).await.unwrap().is_dir());
|
||||
assert!(
|
||||
tokio::fs::metadata(store.root().join("alloc_dir_1").join("metadata.json"))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let resolved = store.resolve(&handle("alloc_dir_1"), 1_500).await.unwrap();
|
||||
assert_eq!(resolved.state, ManagedTempState::Active);
|
||||
assert_eq!(resolved.last_access_at_ms, 1_500);
|
||||
|
||||
let released = store.release(&handle("alloc_dir_1"), 1_600).await.unwrap();
|
||||
assert_eq!(released.state, ManagedTempState::Released);
|
||||
assert_eq!(released.released_at_ms, Some(1_600));
|
||||
assert!(matches!(
|
||||
store.resolve(&handle("alloc_dir_1"), 1_700).await,
|
||||
Err(ManagedTempError::NotFound)
|
||||
));
|
||||
|
||||
let report = store.gc(1_700).await.unwrap();
|
||||
assert_eq!(report.deleted, 1);
|
||||
assert!(tokio::fs::metadata(store.root().join("alloc_dir_1"))
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(app_data).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_file_is_not_resolved_and_is_gc_eligible() {
|
||||
let app_data = temp_app_data("file");
|
||||
let store = FsManagedTempStore::new(&app_data);
|
||||
|
||||
let lease = store
|
||||
.allocate(allocation(
|
||||
handle("alloc_file_1"),
|
||||
ManagedTempKind::File,
|
||||
"../../must-not-affect-path",
|
||||
1_000,
|
||||
Some(1_100),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let path = PathBuf::from(lease.path.as_str());
|
||||
assert_under(&path, store.root());
|
||||
assert_eq!(path.file_name().unwrap(), "data");
|
||||
assert!(tokio::fs::metadata(&path).await.unwrap().is_file());
|
||||
|
||||
assert!(matches!(
|
||||
store.resolve(&handle("alloc_file_1"), 1_200).await,
|
||||
Err(ManagedTempError::NotFound)
|
||||
));
|
||||
assert_eq!(store.gc(1_200).await.unwrap().deleted, 1);
|
||||
assert!(tokio::fs::metadata(path).await.is_err());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(app_data).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_path_like_handles_are_rejected_before_filesystem_resolution() {
|
||||
assert!(ManagedTempHandle::new("../escape").is_err());
|
||||
assert!(ManagedTempHandle::new("a/b").is_err());
|
||||
assert!(ManagedTempHandle::new("..\\escape").is_err());
|
||||
assert!(ManagedTempHandle::new("").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_collision_does_not_reuse_existing_allocation() {
|
||||
let app_data = temp_app_data("collision");
|
||||
let store = FsManagedTempStore::new(&app_data);
|
||||
|
||||
store
|
||||
.allocate(allocation(
|
||||
handle("same_handle"),
|
||||
ManagedTempKind::Directory,
|
||||
"first",
|
||||
1_000,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
store
|
||||
.allocate(allocation(
|
||||
handle("same_handle"),
|
||||
ManagedTempKind::Directory,
|
||||
"second",
|
||||
1_100,
|
||||
None,
|
||||
))
|
||||
.await,
|
||||
Err(ManagedTempError::Store(_))
|
||||
));
|
||||
|
||||
let resolved = store.resolve(&handle("same_handle"), 1_200).await.unwrap();
|
||||
assert_eq!(resolved.purpose, "first");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(app_data).await;
|
||||
}
|
||||
Reference in New Issue
Block a user