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:
@ -45,29 +45,31 @@ use crate::dto::{
|
||||
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
||||
ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
|
||||
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
|
||||
ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
AllocateManagedTempRequestDto, AppExitWorkGuardStateDto, AssignSkillRequestDto,
|
||||
AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, ChatPromptAttachmentDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
|
||||
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
||||
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
||||
@ -2310,6 +2312,96 @@ pub async fn clear_chat_draft(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `managed_temp_allocate` — allocate an IdeA-managed temporary file/directory.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed ids, invalid TTL/purpose, or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn managed_temp_allocate(
|
||||
request: AllocateManagedTempRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedTempLeaseResponseDto, ErrorDto> {
|
||||
let owner_project_id = request
|
||||
.owner_project_id
|
||||
.as_deref()
|
||||
.map(parse_project_id)
|
||||
.transpose()?;
|
||||
let output = state
|
||||
.allocate_managed_temp
|
||||
.execute(application::AllocateManagedTempInput {
|
||||
owner_project_id,
|
||||
kind: request.kind,
|
||||
purpose: request.purpose,
|
||||
ttl_ms: request.ttl_ms,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease),
|
||||
})
|
||||
}
|
||||
|
||||
/// `managed_temp_resolve` — resolve an active IdeA-managed temporary handle.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed handles, missing/released/expired handles,
|
||||
/// or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn managed_temp_resolve(
|
||||
request: ManagedTempHandleRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedTempLeaseResponseDto, ErrorDto> {
|
||||
let output = state
|
||||
.resolve_managed_temp
|
||||
.execute(application::ResolveManagedTempInput {
|
||||
handle: request.handle,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease),
|
||||
})
|
||||
}
|
||||
|
||||
/// `managed_temp_release` — release one IdeA-managed temporary handle.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed handles, missing handles, or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn managed_temp_release(
|
||||
request: ManagedTempHandleRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedTempLeaseResponseDto, ErrorDto> {
|
||||
let output = state
|
||||
.release_managed_temp
|
||||
.execute(application::ReleaseManagedTempInput {
|
||||
handle: request.handle,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease),
|
||||
})
|
||||
}
|
||||
|
||||
/// `managed_temp_gc` — delete released or expired managed temporary allocations.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for store failure.
|
||||
#[tauri::command]
|
||||
pub async fn managed_temp_gc(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedTempGcResponseDto, ErrorDto> {
|
||||
let output = state
|
||||
.gc_managed_temp
|
||||
.execute()
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(ManagedTempGcResponseDto {
|
||||
deleted: output.report.deleted,
|
||||
})
|
||||
}
|
||||
|
||||
/// `agent_send` — send a prompt to a live **structured** (chat) session and pump
|
||||
/// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7).
|
||||
///
|
||||
|
||||
@ -18,6 +18,7 @@ pub mod commands;
|
||||
pub mod dto;
|
||||
pub mod embedded_server;
|
||||
pub mod events;
|
||||
pub mod managed_temp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
@ -345,6 +346,10 @@ pub fn run() {
|
||||
commands::read_chat_draft,
|
||||
commands::save_chat_draft,
|
||||
commands::clear_chat_draft,
|
||||
commands::managed_temp_allocate,
|
||||
commands::managed_temp_resolve,
|
||||
commands::managed_temp_release,
|
||||
commands::managed_temp_gc,
|
||||
commands::agent_send,
|
||||
commands::import_chat_attachments,
|
||||
commands::cancel_resume,
|
||||
|
||||
314
crates/app-tauri/src/managed_temp.rs
Normal file
314
crates/app-tauri/src/managed_temp.rs
Normal file
@ -0,0 +1,314 @@
|
||||
//! Desktop-owned MCP managed-temp tool provider.
|
||||
//!
|
||||
//! This module maps public `idea_managed_temp_*` tools to the application
|
||||
//! managed-temp use cases. Tool arguments never include filesystem paths.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
AllocateManagedTemp, AllocateManagedTempInput, GcManagedTemp, ReleaseManagedTemp,
|
||||
ReleaseManagedTempInput, ResolveManagedTemp, ResolveManagedTempInput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::{ManagedTempKind, Project};
|
||||
use infrastructure::{ManagedTempToolError, ManagedTempToolProvider};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dto::{ManagedTempGcResponseDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto};
|
||||
|
||||
/// Concrete provider for the public MCP managed-temp tools.
|
||||
#[derive(Clone)]
|
||||
pub struct AppManagedTempToolProvider {
|
||||
/// Allocation use case.
|
||||
pub allocate: Arc<AllocateManagedTemp>,
|
||||
/// Resolve use case.
|
||||
pub resolve: Arc<ResolveManagedTemp>,
|
||||
/// Release use case.
|
||||
pub release: Arc<ReleaseManagedTemp>,
|
||||
/// Garbage-collection use case.
|
||||
pub gc: Arc<GcManagedTemp>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempToolProvider for AppManagedTempToolProvider {
|
||||
async fn handle_managed_temp_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
_requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, ManagedTempToolError> {
|
||||
let result = match name {
|
||||
"idea_managed_temp_allocate" => {
|
||||
let output = self
|
||||
.allocate
|
||||
.execute(AllocateManagedTempInput {
|
||||
owner_project_id: Some(project.id),
|
||||
kind: managed_temp_kind(required_str(&arguments, "kind")?)?,
|
||||
purpose: required_str(&arguments, "purpose")?.to_owned(),
|
||||
ttl_ms: optional_u64(&arguments, "ttlMs")?,
|
||||
})
|
||||
.await
|
||||
.map_err(managed_temp_error)?;
|
||||
json!(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease)
|
||||
})
|
||||
}
|
||||
"idea_managed_temp_resolve" => {
|
||||
let output = self
|
||||
.resolve
|
||||
.execute(ResolveManagedTempInput {
|
||||
handle: required_str(&arguments, "handle")?.to_owned(),
|
||||
})
|
||||
.await
|
||||
.map_err(managed_temp_error)?;
|
||||
json!(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease)
|
||||
})
|
||||
}
|
||||
"idea_managed_temp_release" => {
|
||||
let output = self
|
||||
.release
|
||||
.execute(ReleaseManagedTempInput {
|
||||
handle: required_str(&arguments, "handle")?.to_owned(),
|
||||
})
|
||||
.await
|
||||
.map_err(managed_temp_error)?;
|
||||
json!(ManagedTempLeaseResponseDto {
|
||||
lease: ManagedTempLeaseDto::from(output.lease)
|
||||
})
|
||||
}
|
||||
"idea_managed_temp_gc" => {
|
||||
let output = self.gc.execute().await.map_err(managed_temp_error)?;
|
||||
json!(ManagedTempGcResponseDto {
|
||||
deleted: output.report.deleted
|
||||
})
|
||||
}
|
||||
other => {
|
||||
return Err(ManagedTempToolError::new(
|
||||
"notFound",
|
||||
format!("unknown managed temp tool: {other}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_temp_error(err: application::AppError) -> ManagedTempToolError {
|
||||
let code = match err.code() {
|
||||
"NOT_FOUND" => "notFound",
|
||||
"INVALID" => "invalid",
|
||||
"STORE" => "store",
|
||||
"FILESYSTEM" => "filesystem",
|
||||
_ => "internal",
|
||||
};
|
||||
ManagedTempToolError::new(code, err.to_string())
|
||||
}
|
||||
|
||||
fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, ManagedTempToolError> {
|
||||
arguments
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ManagedTempToolError::new("invalid", format!("missing {key}")))
|
||||
}
|
||||
|
||||
fn optional_u64(arguments: &Value, key: &str) -> Result<Option<u64>, ManagedTempToolError> {
|
||||
match arguments.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| ManagedTempToolError::new("invalid", format!("{key} must be a u64"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_temp_kind(raw: &str) -> Result<ManagedTempKind, ManagedTempToolError> {
|
||||
match raw {
|
||||
"directory" => Ok(ManagedTempKind::Directory),
|
||||
"file" => Ok(ManagedTempKind::File),
|
||||
_ => Err(ManagedTempToolError::new(
|
||||
"invalid",
|
||||
"kind must be `directory` or `file`",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AllocateManagedTemp, GcManagedTemp, ReleaseManagedTemp, ResolveManagedTemp,
|
||||
MANAGED_TEMP_DEFAULT_TTL_MS,
|
||||
};
|
||||
use domain::ports::{Clock, IdGenerator, LocalPath, ManagedTempStore};
|
||||
use domain::{
|
||||
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
|
||||
ManagedTempLease, ManagedTempState, ProjectId, ProjectPath, RemoteRef,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
50_000
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedIds;
|
||||
|
||||
impl IdGenerator for FixedIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
Uuid::from_u128(0x189)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStore {
|
||||
allocations: Mutex<Vec<ManagedTempAllocation>>,
|
||||
leases: Mutex<HashMap<ManagedTempHandle, ManagedTempLease>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempStore for FakeStore {
|
||||
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> {
|
||||
let mut lease = self
|
||||
.leases
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(handle)
|
||||
.cloned()
|
||||
.ok_or(ManagedTempError::NotFound)?;
|
||||
lease.last_access_at_ms = now_ms;
|
||||
Ok(lease)
|
||||
}
|
||||
|
||||
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: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(7)),
|
||||
"demo",
|
||||
ProjectPath::new("/tmp/managed-temp-provider-project").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn provider(store: Arc<FakeStore>) -> AppManagedTempToolProvider {
|
||||
let store_port = store as Arc<dyn ManagedTempStore>;
|
||||
AppManagedTempToolProvider {
|
||||
allocate: Arc::new(AllocateManagedTemp::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
resolve: Arc::new(ResolveManagedTemp::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
release: Arc::new(ReleaseManagedTemp::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
gc: Arc::new(GcManagedTemp::new(store_port, Arc::new(FixedClock))),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_temp_allocate_tool_uses_current_project_and_generated_handle() {
|
||||
let store = Arc::new(FakeStore::default());
|
||||
let project = project();
|
||||
let result = provider(Arc::clone(&store))
|
||||
.handle_managed_temp_tool(
|
||||
&project,
|
||||
"agent",
|
||||
"idea_managed_temp_allocate",
|
||||
json!({ "kind": "directory", "purpose": "scratch" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result["lease"]["ownerProjectId"], project.id.to_string());
|
||||
assert_eq!(
|
||||
result["lease"]["handle"],
|
||||
"00000000-0000-0000-0000-000000000189"
|
||||
);
|
||||
assert_eq!(
|
||||
result["lease"]["path"],
|
||||
"/managed/00000000-0000-0000-0000-000000000189"
|
||||
);
|
||||
let allocations = store.allocations.lock().unwrap();
|
||||
assert_eq!(allocations.len(), 1);
|
||||
assert_eq!(allocations[0].owner_project_id, Some(project.id));
|
||||
assert_eq!(
|
||||
allocations[0].expires_at_ms,
|
||||
Some(50_000 + MANAGED_TEMP_DEFAULT_TTL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_temp_resolve_tool_rejects_path_like_handle() {
|
||||
let err = provider(Arc::new(FakeStore::default()))
|
||||
.handle_managed_temp_tool(
|
||||
&project(),
|
||||
"agent",
|
||||
"idea_managed_temp_resolve",
|
||||
json!({ "handle": "../escape" }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code, "invalid");
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ use domain::ports::{
|
||||
ToolSpec,
|
||||
};
|
||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
||||
use serde_json::Value;
|
||||
|
||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||
@ -28,6 +28,7 @@ pub struct AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
}
|
||||
|
||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||
@ -101,6 +102,7 @@ impl AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
@ -109,6 +111,7 @@ impl AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions,
|
||||
ticket_tools,
|
||||
template_tools,
|
||||
managed_temp_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,6 +218,20 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
return serde_json::to_string(&value)
|
||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}")));
|
||||
}
|
||||
if infrastructure::orchestrator::mcp::tools::is_managed_temp_tool(name) {
|
||||
let value = self
|
||||
.managed_temp_tools
|
||||
.handle_managed_temp_tool(&project, &requester, name, value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let detail =
|
||||
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
|
||||
ToolInvocationError::Execution(detail)
|
||||
})?;
|
||||
return serde_json::to_string(&value).map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("JSON managed temp tool: {e}"))
|
||||
});
|
||||
}
|
||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||
.map_err(|e| match e {
|
||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||
@ -395,7 +412,7 @@ mod tests {
|
||||
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||
};
|
||||
use infrastructure::{TemplateToolError, TicketToolError};
|
||||
use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -546,6 +563,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeManagedTempTools {
|
||||
calls: Mutex<Vec<(String, String, Value)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempToolProvider for FakeManagedTempTools {
|
||||
async fn handle_managed_temp_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, ManagedTempToolError> {
|
||||
self.calls.lock().unwrap().push((
|
||||
requester.to_owned(),
|
||||
name.to_owned(),
|
||||
arguments.clone(),
|
||||
));
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"requester": requester,
|
||||
"handle": arguments.get("handle").and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_managed_temp_tools() -> Arc<FakeManagedTempTools> {
|
||||
Arc::new(FakeManagedTempTools::default())
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
@ -602,6 +650,7 @@ mod tests {
|
||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||
ticket_tools.clone(),
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
for tool in ["idea_ticket_list", "idea_template_list"] {
|
||||
@ -693,6 +742,7 @@ mod tests {
|
||||
)),
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let names = invoker
|
||||
@ -733,6 +783,7 @@ mod tests {
|
||||
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
@ -779,6 +830,7 @@ mod tests {
|
||||
mcp_permissions(allow_doc(agent, &["idea_template_update"])),
|
||||
ticket_tools,
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
@ -853,6 +905,7 @@ mod tests {
|
||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let denied = invoker
|
||||
|
||||
@ -12,11 +12,12 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use backend::BackendCore;
|
||||
use domain::PersistedPluginLayoutWindow;
|
||||
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
use crate::embedded_server::EmbeddedServerController;
|
||||
use crate::managed_temp::AppManagedTempToolProvider;
|
||||
use crate::pty::PtyBridge;
|
||||
use crate::templates::AppTemplateToolProvider;
|
||||
use crate::tickets::AppTicketToolProvider;
|
||||
@ -90,6 +91,13 @@ impl AppState {
|
||||
update: Arc::clone(&core.update_template),
|
||||
delete: Arc::clone(&core.delete_template),
|
||||
}) as Arc<dyn TemplateToolProvider>);
|
||||
core.managed_temp_tool_binder
|
||||
.bind(Arc::new(AppManagedTempToolProvider {
|
||||
allocate: Arc::clone(&core.allocate_managed_temp),
|
||||
resolve: Arc::clone(&core.resolve_managed_temp),
|
||||
release: Arc::clone(&core.release_managed_temp),
|
||||
gc: Arc::clone(&core.gc_managed_temp),
|
||||
}) as Arc<dyn ManagedTempToolProvider>);
|
||||
|
||||
Self {
|
||||
core,
|
||||
|
||||
@ -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(_)));
|
||||
}
|
||||
@ -3206,6 +3206,89 @@ pub struct ChatAttachmentDto {
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
/// Request for allocating an IdeA-managed temporary file/directory.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AllocateManagedTempRequestDto {
|
||||
/// Optional project that requested this allocation.
|
||||
#[serde(default)]
|
||||
pub owner_project_id: Option<String>,
|
||||
/// Physical allocation kind (`directory` or `file`).
|
||||
pub kind: domain::ManagedTempKind,
|
||||
/// Short human/debug purpose retained in metadata.
|
||||
pub purpose: String,
|
||||
/// Optional TTL override in milliseconds.
|
||||
#[serde(default)]
|
||||
pub ttl_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Request for resolving/releasing one managed temporary handle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagedTempHandleRequestDto {
|
||||
/// Opaque managed temporary handle.
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// Managed temporary lease DTO.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagedTempLeaseDto {
|
||||
/// Opaque managed temporary handle.
|
||||
pub handle: String,
|
||||
/// Physical allocation kind.
|
||||
pub kind: domain::ManagedTempKind,
|
||||
/// Optional project that requested this allocation.
|
||||
pub owner_project_id: Option<String>,
|
||||
/// Short human/debug purpose.
|
||||
pub purpose: String,
|
||||
/// IdeA-controlled local path for the allocation data.
|
||||
pub path: String,
|
||||
/// Lease state.
|
||||
pub state: domain::ManagedTempState,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last successful access time, epoch milliseconds.
|
||||
pub last_access_at_ms: u64,
|
||||
/// Expiry time, epoch milliseconds.
|
||||
pub expires_at_ms: Option<u64>,
|
||||
/// Explicit release time, epoch milliseconds.
|
||||
pub released_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<domain::ManagedTempLease> for ManagedTempLeaseDto {
|
||||
fn from(lease: domain::ManagedTempLease) -> Self {
|
||||
Self {
|
||||
handle: lease.handle.as_str().to_owned(),
|
||||
kind: lease.kind,
|
||||
owner_project_id: lease.owner_project_id.map(|id| id.to_string()),
|
||||
purpose: lease.purpose,
|
||||
path: lease.path.as_str().to_owned(),
|
||||
state: lease.state,
|
||||
created_at_ms: lease.created_at_ms,
|
||||
last_access_at_ms: lease.last_access_at_ms,
|
||||
expires_at_ms: lease.expires_at_ms,
|
||||
released_at_ms: lease.released_at_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response containing one managed temporary lease.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagedTempLeaseResponseDto {
|
||||
/// Managed temporary lease.
|
||||
pub lease: ManagedTempLeaseDto,
|
||||
}
|
||||
|
||||
/// Managed temporary GC response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagedTempGcResponseDto {
|
||||
/// Number of deleted allocations.
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
/// Canonical attachment projection attached to a streamed user prompt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -13,24 +13,24 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||
use application::{
|
||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft,
|
||||
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
||||
GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
||||
GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||
HealthUseCase, ImportChatAttachments, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError,
|
||||
AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent,
|
||||
AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority,
|
||||
BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||
ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab,
|
||||
CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
|
||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand,
|
||||
FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates,
|
||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||
@ -44,21 +44,21 @@ use application::{
|
||||
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
|
||||
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
||||
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, RenameDevice, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities,
|
||||
ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks,
|
||||
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||
RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer,
|
||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -66,11 +66,11 @@ use domain::ports::{
|
||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
||||
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
||||
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore,
|
||||
PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore,
|
||||
PluginStorageStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort,
|
||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
};
|
||||
@ -94,7 +94,7 @@ use infrastructure::{
|
||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
||||
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
||||
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||
@ -102,8 +102,8 @@ use infrastructure::{
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
LocalManagedProcess, LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
@ -900,6 +900,48 @@ impl AgentResumer for AppAgentResumer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LateBoundManagedTempToolProvider {
|
||||
inner: Arc<Mutex<Option<Arc<dyn ManagedTempToolProvider>>>>,
|
||||
}
|
||||
|
||||
impl LateBoundManagedTempToolProvider {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn bind(&self, inner: Arc<dyn ManagedTempToolProvider>) {
|
||||
*self.inner.lock().expect("managed temp tool mutex poisoned") = Some(inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempToolProvider for LateBoundManagedTempToolProvider {
|
||||
async fn handle_managed_temp_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, infrastructure::ManagedTempToolError> {
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("managed temp tool mutex poisoned")
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
infrastructure::ManagedTempToolError::new(
|
||||
"UNAVAILABLE",
|
||||
"managed temp tool provider not bound".to_owned(),
|
||||
)
|
||||
})?;
|
||||
inner
|
||||
.handle_managed_temp_tool(project, requester, name, arguments)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a driving adapter needs at runtime.
|
||||
///
|
||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||
@ -1054,6 +1096,14 @@ pub struct BackendCore {
|
||||
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
||||
/// Import durable agent/chat attachments.
|
||||
pub import_chat_attachments: Arc<ImportChatAttachments>,
|
||||
/// Allocate an IdeA-managed temporary file/directory.
|
||||
pub allocate_managed_temp: Arc<AllocateManagedTemp>,
|
||||
/// Resolve an active IdeA-managed temporary handle.
|
||||
pub resolve_managed_temp: Arc<ResolveManagedTemp>,
|
||||
/// Release an IdeA-managed temporary handle.
|
||||
pub release_managed_temp: Arc<ReleaseManagedTemp>,
|
||||
/// Garbage-collect released or expired managed temporary allocations.
|
||||
pub gc_managed_temp: Arc<GcManagedTemp>,
|
||||
/// Read one persisted custom chat draft.
|
||||
pub read_chat_draft: Arc<ReadChatDraft>,
|
||||
/// Save one persisted custom chat draft.
|
||||
@ -1084,6 +1134,8 @@ pub struct BackendCore {
|
||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||
/// MCP provider for the public `idea_template_*` tools.
|
||||
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
|
||||
/// MCP provider for the public `idea_managed_temp_*` tools.
|
||||
pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>,
|
||||
/// Open an ephemeral AI assistant chat bound to one ticket.
|
||||
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
||||
/// Close an ephemeral AI assistant chat bound to one ticket.
|
||||
@ -1341,6 +1393,8 @@ pub struct BackendCore {
|
||||
pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>,
|
||||
/// Late-bound concrete template MCP provider supplied by the driving adapter.
|
||||
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
|
||||
/// Late-bound concrete managed-temp MCP provider supplied by the driving adapter.
|
||||
pub managed_temp_tool_binder: Arc<LateBoundManagedTempToolProvider>,
|
||||
}
|
||||
|
||||
/// Backend decision for attaching a UI subscriber to a background task.
|
||||
@ -1753,6 +1807,8 @@ impl BackendCore {
|
||||
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
|
||||
let chat_attachment_store_port =
|
||||
Arc::clone(&chat_attachment_store) as Arc<dyn ChatAttachmentStore>;
|
||||
let managed_temp_store = Arc::new(FsManagedTempStore::new(app_data_dir.clone()));
|
||||
let managed_temp_store_port = Arc::clone(&managed_temp_store) as Arc<dyn ManagedTempStore>;
|
||||
let chat_draft_store = Arc::new(FsChatDraftStore::new());
|
||||
let chat_draft_store_port = Arc::clone(&chat_draft_store) as Arc<dyn ChatDraftStore>;
|
||||
let create_issue = Arc::new(CreateIssue::new(
|
||||
@ -1813,6 +1869,23 @@ impl BackendCore {
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let allocate_managed_temp = Arc::new(AllocateManagedTemp::new(
|
||||
Arc::clone(&managed_temp_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let resolve_managed_temp = Arc::new(ResolveManagedTemp::new(
|
||||
Arc::clone(&managed_temp_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let release_managed_temp = Arc::new(ReleaseManagedTemp::new(
|
||||
Arc::clone(&managed_temp_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let gc_managed_temp = Arc::new(GcManagedTemp::new(
|
||||
Arc::clone(&managed_temp_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let read_chat_draft = Arc::new(ReadChatDraft::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&chat_draft_store_port),
|
||||
@ -1885,6 +1958,9 @@ impl BackendCore {
|
||||
let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
|
||||
let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new());
|
||||
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
|
||||
let managed_temp_tool_binder = Arc::new(LateBoundManagedTempToolProvider::new());
|
||||
let managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider> =
|
||||
managed_temp_tool_binder.clone();
|
||||
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
||||
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
||||
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
|
||||
@ -2958,6 +3034,7 @@ impl BackendCore {
|
||||
Arc::clone(&mcp_tool_permission_store_port),
|
||||
Arc::clone(&ticket_tool_provider),
|
||||
Arc::clone(&template_tool_provider),
|
||||
Arc::clone(&managed_temp_tool_provider),
|
||||
)) as Arc<dyn ToolInvoker>);
|
||||
|
||||
let stop_live_agent = Arc::new(
|
||||
@ -3061,6 +3138,10 @@ impl BackendCore {
|
||||
read_issue_attachment,
|
||||
mark_issue_attachment_summarized,
|
||||
import_chat_attachments,
|
||||
allocate_managed_temp,
|
||||
resolve_managed_temp,
|
||||
release_managed_temp,
|
||||
gc_managed_temp,
|
||||
read_chat_draft,
|
||||
save_chat_draft,
|
||||
clear_chat_draft,
|
||||
@ -3076,6 +3157,7 @@ impl BackendCore {
|
||||
unassign_ticket_from_sprint,
|
||||
ticket_tool_provider,
|
||||
template_tool_provider,
|
||||
managed_temp_tool_provider,
|
||||
open_ticket_assistant,
|
||||
close_ticket_assistant,
|
||||
tool_policy_registry,
|
||||
@ -3183,6 +3265,7 @@ impl BackendCore {
|
||||
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
||||
ticket_tool_binder,
|
||||
template_tool_binder,
|
||||
managed_temp_tool_binder,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3274,6 +3357,7 @@ impl BackendCore {
|
||||
.with_ready_sink(ready_sink)
|
||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
||||
.with_template_tools(Arc::clone(&self.template_tool_provider))
|
||||
.with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider))
|
||||
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
|
||||
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
||||
endpoint,
|
||||
|
||||
@ -14,7 +14,7 @@ use domain::ports::{
|
||||
ToolSpec,
|
||||
};
|
||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
||||
use serde_json::Value;
|
||||
|
||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||
@ -28,6 +28,7 @@ pub struct AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
}
|
||||
|
||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||
@ -101,6 +102,7 @@ impl AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||
template_tools: Arc<dyn TemplateToolProvider>,
|
||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
@ -109,6 +111,7 @@ impl AppOpenAiToolInvoker {
|
||||
mcp_tool_permissions,
|
||||
ticket_tools,
|
||||
template_tools,
|
||||
managed_temp_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,6 +218,20 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
return serde_json::to_string(&value)
|
||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}")));
|
||||
}
|
||||
if infrastructure::orchestrator::mcp::tools::is_managed_temp_tool(name) {
|
||||
let value = self
|
||||
.managed_temp_tools
|
||||
.handle_managed_temp_tool(&project, &requester, name, value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let detail =
|
||||
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
|
||||
ToolInvocationError::Execution(detail)
|
||||
})?;
|
||||
return serde_json::to_string(&value).map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("JSON managed temp tool: {e}"))
|
||||
});
|
||||
}
|
||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||
.map_err(|e| match e {
|
||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||
@ -407,7 +424,7 @@ mod tests {
|
||||
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||
};
|
||||
use infrastructure::{TemplateToolError, TicketToolError};
|
||||
use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -558,6 +575,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeManagedTempTools {
|
||||
calls: Mutex<Vec<(String, String, Value)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempToolProvider for FakeManagedTempTools {
|
||||
async fn handle_managed_temp_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, ManagedTempToolError> {
|
||||
self.calls.lock().unwrap().push((
|
||||
requester.to_owned(),
|
||||
name.to_owned(),
|
||||
arguments.clone(),
|
||||
));
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"requester": requester,
|
||||
"handle": arguments.get("handle").and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_managed_temp_tools() -> Arc<FakeManagedTempTools> {
|
||||
Arc::new(FakeManagedTempTools::default())
|
||||
}
|
||||
|
||||
fn issue_ref(raw: &str) -> IssueRef {
|
||||
IssueRef::from_str(raw).unwrap()
|
||||
}
|
||||
@ -723,6 +771,7 @@ mod tests {
|
||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let denied = invoker
|
||||
@ -788,6 +837,7 @@ mod tests {
|
||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||
ticket_tools.clone(),
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
for tool in ["idea_ticket_list", "idea_template_list"] {
|
||||
@ -877,6 +927,7 @@ mod tests {
|
||||
)),
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let tools = invoker
|
||||
@ -916,6 +967,7 @@ mod tests {
|
||||
)),
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let tools = invoker
|
||||
@ -948,6 +1000,7 @@ mod tests {
|
||||
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||
ticket_tools.clone(),
|
||||
template_tools,
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
@ -994,6 +1047,7 @@ mod tests {
|
||||
mcp_permissions(allow_doc(agent, &["idea_template_update"])),
|
||||
ticket_tools,
|
||||
template_tools.clone(),
|
||||
fake_managed_temp_tools(),
|
||||
);
|
||||
|
||||
let result = invoker
|
||||
|
||||
@ -49,6 +49,7 @@ pub mod issue;
|
||||
pub mod layout;
|
||||
pub mod live_state;
|
||||
pub mod mailbox;
|
||||
pub mod managed_temp;
|
||||
pub mod markdown;
|
||||
pub mod mcp_tool_permissions;
|
||||
pub mod memory;
|
||||
@ -127,6 +128,10 @@ pub use mailbox::{
|
||||
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
|
||||
TicketId,
|
||||
};
|
||||
pub use managed_temp::{
|
||||
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
|
||||
ManagedTempKind, ManagedTempLease, ManagedTempState,
|
||||
};
|
||||
|
||||
pub use conversation::{
|
||||
Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry,
|
||||
|
||||
122
crates/domain/src/managed_temp.rs
Normal file
122
crates/domain/src/managed_temp.rs
Normal file
@ -0,0 +1,122 @@
|
||||
//! Managed temporary storage model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::ProjectId;
|
||||
use crate::ports::LocalPath;
|
||||
|
||||
/// Opaque handle for an IdeA-managed temporary allocation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ManagedTempHandle(String);
|
||||
|
||||
impl ManagedTempHandle {
|
||||
/// Builds a handle.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ManagedTempError::Invalid`] when the handle is empty or path-like.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ManagedTempError> {
|
||||
let value = value.into();
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ManagedTempError::Invalid(
|
||||
"invalid managed temp handle".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw handle.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical allocation kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ManagedTempKind {
|
||||
/// A directory allocation.
|
||||
Directory,
|
||||
/// An empty file allocation.
|
||||
File,
|
||||
}
|
||||
|
||||
/// Lease lifecycle state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ManagedTempState {
|
||||
/// The allocation can still be resolved.
|
||||
Active,
|
||||
/// The allocation was explicitly released and is GC-eligible.
|
||||
Released,
|
||||
}
|
||||
|
||||
/// Allocation request persisted by the store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedTempAllocation {
|
||||
/// Opaque handle allocated by the application layer.
|
||||
pub handle: ManagedTempHandle,
|
||||
/// Physical allocation kind.
|
||||
pub kind: ManagedTempKind,
|
||||
/// Optional project that requested the allocation.
|
||||
pub owner_project_id: Option<ProjectId>,
|
||||
/// Short human/debug purpose.
|
||||
pub purpose: String,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Optional expiry time, epoch milliseconds.
|
||||
pub expires_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolved managed temporary lease.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedTempLease {
|
||||
/// Opaque handle.
|
||||
pub handle: ManagedTempHandle,
|
||||
/// Physical allocation kind.
|
||||
pub kind: ManagedTempKind,
|
||||
/// Optional project that requested the allocation.
|
||||
pub owner_project_id: Option<ProjectId>,
|
||||
/// Short human/debug purpose.
|
||||
pub purpose: String,
|
||||
/// Managed local path. It is produced by IdeA, never accepted as user input.
|
||||
pub path: LocalPath,
|
||||
/// Lease state.
|
||||
pub state: ManagedTempState,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last successful resolve time, epoch milliseconds.
|
||||
pub last_access_at_ms: u64,
|
||||
/// Optional expiry time, epoch milliseconds.
|
||||
pub expires_at_ms: Option<u64>,
|
||||
/// Explicit release time, epoch milliseconds.
|
||||
pub released_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// GC outcome.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedTempGcReport {
|
||||
/// Number of deleted allocations.
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
/// Managed temporary store errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ManagedTempError {
|
||||
/// Handle does not exist or is no longer resolvable.
|
||||
#[error("managed temp not found")]
|
||||
NotFound,
|
||||
/// Invalid input or persisted metadata.
|
||||
#[error("managed temp invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Store I/O or serialization failed.
|
||||
#[error("managed temp store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
@ -45,6 +45,10 @@ use crate::issue::{
|
||||
Issue, IssueAttachment, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter,
|
||||
IssueNumber, IssueRef, IssueVersion,
|
||||
};
|
||||
use crate::managed_temp::{
|
||||
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
|
||||
ManagedTempLease,
|
||||
};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
@ -1320,6 +1324,33 @@ pub trait ChatAttachmentStore: Send + Sync {
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
|
||||
}
|
||||
|
||||
/// Store for IdeA-managed temporary allocations shared across projects.
|
||||
#[async_trait]
|
||||
pub trait ManagedTempStore: Send + Sync {
|
||||
/// Allocates a new managed temporary file/directory.
|
||||
async fn allocate(
|
||||
&self,
|
||||
allocation: ManagedTempAllocation,
|
||||
) -> Result<ManagedTempLease, ManagedTempError>;
|
||||
|
||||
/// Resolves an active handle and updates its last access timestamp.
|
||||
async fn resolve(
|
||||
&self,
|
||||
handle: &ManagedTempHandle,
|
||||
now_ms: u64,
|
||||
) -> Result<ManagedTempLease, ManagedTempError>;
|
||||
|
||||
/// Releases an allocation, making it eligible for GC.
|
||||
async fn release(
|
||||
&self,
|
||||
handle: &ManagedTempHandle,
|
||||
now_ms: u64,
|
||||
) -> Result<ManagedTempLease, ManagedTempError>;
|
||||
|
||||
/// Deletes released or expired allocations.
|
||||
async fn gc(&self, now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError>;
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped custom chat drafts.
|
||||
#[async_trait]
|
||||
pub trait ChatDraftStore: Send + Sync {
|
||||
|
||||
@ -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