From fdca55ecd41f06b33f8620b0b05d2b8775da80f9 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 13 Aug 2026 13:34:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(infra):=20tmp=20partag=C3=A9=20inter-proje?= =?UTF-8?q?ts=20g=C3=A9r=C3=A9=20par=20IdeA=20(handles=20opaques,=20leases?= =?UTF-8?q?,=20GC,=20projection=20sandbox)=20=E2=80=94=20#189=20(QA=20vert?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/app-tauri/src/commands.rs | 138 ++++++-- crates/app-tauri/src/lib.rs | 5 + crates/app-tauri/src/managed_temp.rs | 314 ++++++++++++++++++ crates/app-tauri/src/openai_tools.rs | 57 +++- crates/app-tauri/src/state.rs | 10 +- crates/application/src/error.rs | 12 +- crates/application/src/lib.rs | 7 + crates/application/src/managed_temp.rs | 209 ++++++++++++ crates/application/tests/managed_temp.rs | 209 ++++++++++++ crates/backend/src/dto.rs | 83 +++++ crates/backend/src/lib.rs | 166 ++++++--- crates/backend/src/openai_tools.rs | 58 +++- crates/domain/src/lib.rs | 5 + crates/domain/src/managed_temp.rs | 122 +++++++ crates/domain/src/ports.rs | 31 ++ crates/infrastructure/src/lib.rs | 7 +- crates/infrastructure/src/managed_temp.rs | 241 ++++++++++++++ .../src/orchestrator/mcp/managed_temp.rs | 122 +++++++ .../src/orchestrator/mcp/mod.rs | 2 + .../src/orchestrator/mcp/server.rs | 54 +++ .../src/orchestrator/mcp/tools.rs | 45 +++ crates/infrastructure/tests/managed_temp.rs | 175 ++++++++++ 22 files changed, 2000 insertions(+), 72 deletions(-) create mode 100644 crates/app-tauri/src/managed_temp.rs create mode 100644 crates/application/src/managed_temp.rs create mode 100644 crates/application/tests/managed_temp.rs create mode 100644 crates/domain/src/managed_temp.rs create mode 100644 crates/infrastructure/src/managed_temp.rs create mode 100644 crates/infrastructure/src/orchestrator/mcp/managed_temp.rs create mode 100644 crates/infrastructure/tests/managed_temp.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index cf9ea895..51b9f7cc 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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). /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 882d894a..4cd29909 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/src/managed_temp.rs b/crates/app-tauri/src/managed_temp.rs new file mode 100644 index 00000000..cd297525 --- /dev/null +++ b/crates/app-tauri/src/managed_temp.rs @@ -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, + /// Resolve use case. + pub resolve: Arc, + /// Release use case. + pub release: Arc, + /// Garbage-collection use case. + pub gc: Arc, +} + +#[async_trait] +impl ManagedTempToolProvider for AppManagedTempToolProvider { + async fn handle_managed_temp_tool( + &self, + project: &Project, + _requester: &str, + name: &str, + arguments: Value, + ) -> Result { + 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, 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 { + 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>, + leases: Mutex>, + } + + #[async_trait] + impl ManagedTempStore for FakeStore { + async fn allocate( + &self, + allocation: ManagedTempAllocation, + ) -> Result { + 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 { + 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 { + 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 { + 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) -> AppManagedTempToolProvider { + let store_port = store as Arc; + 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"); + } +} diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 438afa60..7b368a61 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -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, ticket_tools: Arc, template_tools: Arc, + managed_temp_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -101,6 +102,7 @@ impl AppOpenAiToolInvoker { mcp_tool_permissions: Arc, ticket_tools: Arc, template_tools: Arc, + managed_temp_tools: Arc, ) -> 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>, + } + + #[async_trait] + impl ManagedTempToolProvider for FakeManagedTempTools { + async fn handle_managed_temp_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + 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 { + 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 diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 879a2e18..4efd5ddb 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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); + 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); Self { core, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 7135fd56..d1b19a7d 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -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 for AppError { } } +impl From 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 for AppError { fn from(e: MemoryError) -> Self { match e { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index e783c567..6da1b719 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/managed_temp.rs b/crates/application/src/managed_temp.rs new file mode 100644 index 00000000..db38a989 --- /dev/null +++ b/crates/application/src/managed_temp.rs @@ -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, + /// 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, +} + +/// 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, + ids: Arc, + clock: Arc, +} + +impl AllocateManagedTemp { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + ids: Arc, + clock: Arc, + ) -> Self { + Self { store, ids, clock } + } + + /// Executes allocation. + pub async fn execute( + &self, + input: AllocateManagedTempInput, + ) -> Result { + 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, + clock: Arc, +} + +impl ResolveManagedTemp { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Executes resolution. + pub async fn execute( + &self, + input: ResolveManagedTempInput, + ) -> Result { + 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, + clock: Arc, +} + +impl ReleaseManagedTemp { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Executes release. + pub async fn execute( + &self, + input: ReleaseManagedTempInput, + ) -> Result { + 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, + clock: Arc, +} + +impl GcManagedTemp { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Executes GC. + pub async fn execute(&self) -> Result { + let report = self.store.gc(now_ms(&self.clock)).await?; + Ok(GcManagedTempOutput { report }) + } +} + +fn now_ms(clock: &Arc) -> u64 { + u64::try_from(clock.now_millis()).unwrap_or(0) +} + +fn bounded_purpose(raw: String) -> Result { + 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()) +} diff --git a/crates/application/tests/managed_temp.rs b/crates/application/tests/managed_temp.rs new file mode 100644 index 00000000..350c35c6 --- /dev/null +++ b/crates/application/tests/managed_temp.rs @@ -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>, + leases: Mutex>, +} + +#[async_trait] +impl ManagedTempStore for FakeManagedTempStore { + async fn allocate( + &self, + allocation: ManagedTempAllocation, + ) -> Result { + 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 { + self.leases + .lock() + .unwrap() + .get(handle) + .cloned() + .ok_or(ManagedTempError::NotFound) + } + + async fn release( + &self, + handle: &ManagedTempHandle, + now_ms: u64, + ) -> Result { + 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 { + 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, + 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, + 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, + Arc::new(FixedClock), + ); + + let err = use_case + .execute(ResolveManagedTempInput { + handle: "../escape".to_owned(), + }) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::Invalid(_))); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index b29f4d8a..fb43fe31 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -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, + /// 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, +} + +/// 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, + /// 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, + /// Explicit release time, epoch milliseconds. + pub released_at_ms: Option, +} + +impl From 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")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 57b46421..fd088878 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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>>>, +} + +impl LateBoundManagedTempToolProvider { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn bind(&self, inner: Arc) { + *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 { + 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, /// Import durable agent/chat attachments. pub import_chat_attachments: Arc, + /// Allocate an IdeA-managed temporary file/directory. + pub allocate_managed_temp: Arc, + /// Resolve an active IdeA-managed temporary handle. + pub resolve_managed_temp: Arc, + /// Release an IdeA-managed temporary handle. + pub release_managed_temp: Arc, + /// Garbage-collect released or expired managed temporary allocations. + pub gc_managed_temp: Arc, /// Read one persisted custom chat draft. pub read_chat_draft: Arc, /// Save one persisted custom chat draft. @@ -1084,6 +1134,8 @@ pub struct BackendCore { pub(crate) ticket_tool_provider: Arc, /// MCP provider for the public `idea_template_*` tools. pub(crate) template_tool_provider: Arc, + /// MCP provider for the public `idea_managed_temp_*` tools. + pub(crate) managed_temp_tool_provider: Arc, /// Open an ephemeral AI assistant chat bound to one ticket. pub open_ticket_assistant: Arc, /// Close an ephemeral AI assistant chat bound to one ticket. @@ -1341,6 +1393,8 @@ pub struct BackendCore { pub ticket_tool_binder: Arc, /// Late-bound concrete template MCP provider supplied by the driving adapter. pub template_tool_binder: Arc, + /// Late-bound concrete managed-temp MCP provider supplied by the driving adapter. + pub managed_temp_tool_binder: Arc, } /// 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; + let managed_temp_store = Arc::new(FsManagedTempStore::new(app_data_dir.clone())); + let managed_temp_store_port = Arc::clone(&managed_temp_store) as Arc; let chat_draft_store = Arc::new(FsChatDraftStore::new()); let chat_draft_store_port = Arc::clone(&chat_draft_store) as Arc; let create_issue = Arc::new(CreateIssue::new( @@ -1813,6 +1869,23 @@ impl BackendCore { Arc::clone(&ids) as Arc, Arc::clone(&clock) as Arc, )); + let allocate_managed_temp = Arc::new(AllocateManagedTemp::new( + Arc::clone(&managed_temp_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + )); + let resolve_managed_temp = Arc::new(ResolveManagedTemp::new( + Arc::clone(&managed_temp_store_port), + Arc::clone(&clock) as Arc, + )); + let release_managed_temp = Arc::new(ReleaseManagedTemp::new( + Arc::clone(&managed_temp_store_port), + Arc::clone(&clock) as Arc, + )); + let gc_managed_temp = Arc::new(GcManagedTemp::new( + Arc::clone(&managed_temp_store_port), + Arc::clone(&clock) as Arc, + )); 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 = ticket_tool_binder.clone(); let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new()); let template_tool_provider: Arc = template_tool_binder.clone(); + let managed_temp_tool_binder = Arc::new(LateBoundManagedTempToolProvider::new()); + let managed_temp_tool_provider: Arc = + managed_temp_tool_binder.clone(); let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; 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); 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, diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 177c8eff..6b83ddcd 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -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, ticket_tools: Arc, template_tools: Arc, + managed_temp_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -101,6 +102,7 @@ impl AppOpenAiToolInvoker { mcp_tool_permissions: Arc, ticket_tools: Arc, template_tools: Arc, + managed_temp_tools: Arc, ) -> 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>, + } + + #[async_trait] + impl ManagedTempToolProvider for FakeManagedTempTools { + async fn handle_managed_temp_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + 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 { + 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 diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index c3fba43b..31e5b728 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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, diff --git a/crates/domain/src/managed_temp.rs b/crates/domain/src/managed_temp.rs new file mode 100644 index 00000000..22adc475 --- /dev/null +++ b/crates/domain/src/managed_temp.rs @@ -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) -> Result { + 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, + /// 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, +} + +/// 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, + /// 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, + /// Explicit release time, epoch milliseconds. + pub released_at_ms: Option, +} + +/// 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), +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 7ed96a57..3a436200 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, 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; + + /// Resolves an active handle and updates its last access timestamp. + async fn resolve( + &self, + handle: &ManagedTempHandle, + now_ms: u64, + ) -> Result; + + /// Releases an allocation, making it eligible for GC. + async fn release( + &self, + handle: &ManagedTempHandle, + now_ms: u64, + ) -> Result; + + /// Deletes released or expired allocations. + async fn gc(&self, now_ms: u64) -> Result; +} + /// Persistence port for project-scoped custom chat drafts. #[async_trait] pub trait ChatDraftStore: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index c46d95f4..97dcb898 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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, diff --git a/crates/infrastructure/src/managed_temp.rs b/crates/infrastructure/src/managed_temp.rs new file mode 100644 index 00000000..cfa65b53 --- /dev/null +++ b/crates/infrastructure/src/managed_temp.rs @@ -0,0 +1,241 @@ +//! Filesystem-backed managed temporary store. +//! +//! Allocations live under `/managed-tmp//`. + +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 `/managed-tmp`. + #[must_use] + pub fn new(app_data_dir: impl Into) -> 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, + purpose: String, + state: ManagedTempState, + created_at_ms: u64, + last_access_at_ms: u64, + expires_at_ms: Option, + released_at_ms: Option, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 }) + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/managed_temp.rs b/crates/infrastructure/src/orchestrator/mcp/managed_temp.rs new file mode 100644 index 00000000..262719a4 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/managed_temp.rs @@ -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) -> 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; +} + +/// 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 { + 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 + }), + }, + ] +} diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index bb62bd5e..6d778273 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -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}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 5e245fa9..368da9f9 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -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>, + /// 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>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, /// 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, + ) -> 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) -> 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) { diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index ccf41b0e..5f4b2676 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -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 { ]; 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) + ); + } } diff --git a/crates/infrastructure/tests/managed_temp.rs b/crates/infrastructure/tests/managed_temp.rs new file mode 100644 index 00000000..d61d6237 --- /dev/null +++ b/crates/infrastructure/tests/managed_temp.rs @@ -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, +) -> 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; +}