diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index fdf9831..3eb87e9 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -14,9 +14,10 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, - CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, + CloseTerminal, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, - LaunchAgentInput, SessionLimitService, + LaunchAgentInput, ProposeContext, ReadContext, ReadMemory, SessionLimitService, WriteMemory, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph, @@ -55,7 +56,7 @@ use infrastructure::{ FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, + OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -1066,6 +1067,31 @@ impl AppState { // vivante keyée par conversation (lève l'ambiguïté session/agent). let conversation_registry = Arc::new(InMemoryConversationRegistry::new()) as Arc; + // Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en + // `Arc` puis cloné dans les quatre use cases, pour que les leases + // se coordonnent entre eux et que l'invariant single-writer du contexte projet tienne. + let file_guard = Arc::new(RwFileGuard::new()) as Arc; + let context_guard = Arc::new(ContextGuardUseCases { + read_context: Arc::new(ReadContext::new( + Arc::clone(&file_guard), + Arc::clone(&contexts_port), + Arc::clone(&fs_port), + )), + propose_context: Arc::new(ProposeContext::new( + Arc::clone(&file_guard), + Arc::clone(&contexts_port), + Arc::clone(&fs_port), + Arc::clone(&clock) as Arc, + )), + read_memory: Arc::new(ReadMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory_store_port), + )), + write_memory: Arc::new(WriteMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory_store_port), + )), + }); let orchestrator_service = Arc::new( OrchestratorService::new( Arc::clone(&create_agent), @@ -1095,7 +1121,12 @@ impl AppState { .with_record_turn( Arc::new(AppRecordTurnProvider) as Arc, Arc::clone(&clock) as Arc, - ), + ) + // FileGuard context/memory (cadrage C7) : branche les quatre use cases + // ReadContext/ProposeContext/ReadMemory/WriteMemory derrière le garde partagé. + // Sans ça, `require_context_guard()` reste `None` ⇒ les outils MCP + // `idea_context_*`/`idea_memory_*` échouent pour tous les agents. + .with_context_guard(context_guard), // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc @@ -3080,6 +3111,235 @@ mod mcp_serve_peer_tests { // Missing fields ⇒ empty strings. assert_eq!(parse_handshake("{}"), (String::new(), String::new())); } + + // ----------------------------------------------------------------------- + // C7 — câblage du FileGuard context/memory au composition root. + // + // Régression visée (fix `fix/wire-context-guard`) : si quelqu'un oublie à + // nouveau `.with_context_guard(...)`, `require_context_guard()` retombe sur + // `None` et toute commande `context.*`/`memory.*` échoue avec + // `AppError::Invalid("FileGuard context/memory tools are not configured")`. + // Ces tests prouvent que, branché comme dans `App::build`, le service + // traite ces commandes sans cette erreur — et le réfute sans le câblage. + // ----------------------------------------------------------------------- + + use application::{ + ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory, + }; + use domain::conversation::ConversationParty; + use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; + use domain::ports::{Clock, MemoryError, MemoryStore}; + use domain::OrchestratorCommand; + use infrastructure::RwFileGuard; + + /// In-memory [`MemoryStore`] (slug → body), the lightest fake that lets the + /// `memory.write` → `memory.read` round-trip exercise the real use cases + /// behind the shared guard. Mirrors the one in `context_guard.rs`'s tests. + #[derive(Default)] + struct FakeMemory { + notes: Mutex>, + } + #[async_trait] + impl MemoryStore for FakeMemory { + async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { + let body = self + .notes + .lock() + .unwrap() + .get(slug.as_str()) + .cloned() + .ok_or(MemoryError::NotFound)?; + Memory::new( + MemoryFrontmatter { + name: slug.clone(), + description: "d".to_owned(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new(body), + ) + .map_err(|e| MemoryError::Frontmatter(e.to_string())) + } + async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { + self.notes + .lock() + .unwrap() + .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); + Ok(()) + } + async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { + Ok(()) + } + async fn read_index( + &self, + _root: &ProjectPath, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + } + + /// Fixed millis clock — `ProposeContext` needs a [`Clock`], unused on the + /// paths these tests drive. + struct FixedClock; + impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 1_700_000_000_000 + } + } + + /// Builds the **same wiring as `App::build`** (state.rs:1070-1129): one + /// shared `RwFileGuard` cast once, cloned into the four C7 use cases, handed + /// to the service via `.with_context_guard(...)`. Returns the service plus + /// the shared `FakeMemory` so a test can assert the persisted note. + fn build_service_with_guard( + contexts: FakeContexts, + ) -> (Arc, Arc) { + let memory = Arc::new(FakeMemory::default()); + let file_guard = + Arc::new(RwFileGuard::new()) as Arc; + let context_guard = Arc::new(ContextGuardUseCases { + read_context: Arc::new(ReadContext::new( + Arc::clone(&file_guard), + Arc::new(contexts.clone()), + Arc::new(FakeFs), + )), + propose_context: Arc::new(ProposeContext::new( + Arc::clone(&file_guard), + Arc::new(contexts.clone()), + Arc::new(FakeFs), + Arc::new(FixedClock), + )), + read_memory: Arc::new(ReadMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory) as Arc, + )), + write_memory: Arc::new(WriteMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory) as Arc, + )), + }); + // Rewrap `build_service`'s service with the guard. `build_service` + // already produces a fully-wired `OrchestratorService` over the same + // fakes; `.with_context_guard` is additive, exactly the prod builder. + let service = build_service(contexts); + let service = Arc::try_unwrap(service) + .map_err(|_| ()) + .expect("freshly built service is uniquely owned") + .with_context_guard(context_guard); + (Arc::new(service), memory) + } + + /// The party an MCP agent presents to the guard — an agent (not the + /// orchestrator), the realistic caller of `idea_memory_*`/`idea_context_*`. + fn agent_party(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n))) + } + + /// WIRING — with `.with_context_guard(...)`, `memory.write` then + /// `memory.read` round-trips the content instead of erroring "not + /// configured". Proves the four use cases reached the dispatch. + #[tokio::test] + async fn wired_guard_serves_memory_read_write_round_trip() { + let proj = project(); + let (service, _memory) = build_service_with_guard(FakeContexts::new()); + + // memory.write — must not return the "not configured" sentinel. + let write = service + .dispatch( + &proj, + OrchestratorCommand::WriteMemory { + slug: "wiring-note".to_owned(), + content: "guard is wired".to_owned(), + requester: agent_party(1), + }, + ) + .await + .expect("memory.write must succeed when the guard is wired"); + assert_eq!(write.detail, "wrote memory wiring-note"); + + // memory.read on the SAME shared guard/store — returns the body. + let read = service + .dispatch( + &proj, + OrchestratorCommand::ReadMemory { + slug: Some("wiring-note".to_owned()), + requester: agent_party(2), + }, + ) + .await + .expect("memory.read must succeed when the guard is wired"); + assert_eq!(read.reply.as_deref(), Some("guard is wired")); + } + + /// WIRING — `context.read` on a seeded agent target returns its `.md` body + /// (not the "not configured" error), exercising `ReadContext` end-to-end. + #[tokio::test] + async fn wired_guard_serves_context_read() { + let proj = project(); + let contexts = FakeContexts::new(); + let agent = contexts.seed_agent("dev-backend"); + // Seed the agent's context body via the store the use case reads from. + { + let md = contexts.md_path_of(&agent).unwrap(); + contexts + .0 + .lock() + .unwrap() + .contents + .insert(md, "# Dev Backend context".to_owned()); + } + let (service, _memory) = build_service_with_guard(contexts); + + let out = service + .dispatch( + &proj, + OrchestratorCommand::ReadContext { + target: Some("dev-backend".to_owned()), + requester: agent_party(3), + }, + ) + .await + .expect("context.read must succeed when the guard is wired"); + assert_eq!(out.reply.as_deref(), Some("# Dev Backend context")); + assert_eq!(out.detail, "read dev-backend context"); + } + + /// SYMMETRY — without the guard, the very same command yields the typed + /// `AppError::Invalid("FileGuard … not configured")`. Documents the contract + /// of `require_context_guard` and pins the exact regression message. + #[tokio::test] + async fn unwired_service_rejects_memory_write_with_typed_error() { + let proj = project(); + // `build_service` does NOT call `.with_context_guard`. + let service = build_service(FakeContexts::new()); + + let err = service + .dispatch( + &proj, + OrchestratorCommand::WriteMemory { + slug: "wiring-note".to_owned(), + content: "x".to_owned(), + requester: agent_party(1), + }, + ) + .await + .expect_err("memory.write must fail without the guard wired"); + assert_eq!( + err, + application::AppError::Invalid( + "FileGuard context/memory tools are not configured".to_owned() + ) + ); + } } #[cfg(test)] diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 4a0a42c..85bb602 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -78,7 +78,8 @@ pub use memory::{ ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use orchestrator::{ - McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider, + ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, + ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index 0d13586..4df8b38 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -12,5 +12,6 @@ pub use context_guard::{ ReadMemoryInput, WriteMemory, WriteMemoryInput, }; pub use service::{ - McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider, + ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, + RecordTurnProvider, };