fix(orchestrator): câble le ContextGuard au composition root

Le composition root (app-tauri/state.rs) n'appelait pas
.with_context_guard(...) : les outils MCP de contexte/mémoire
(idea_context_read/propose, idea_memory_read/write) n'étaient
donc pas réellement branchés au runtime, alors que le use case
existait côté application.

- application: re-export public de ContextGuardUseCases (orchestrator/mod.rs)
  et de ContextGuardUseCases, ReadContext, ProposeContext, ReadMemory,
  WriteMemory (lib.rs), pour que app-tauri puisse les câbler.
- app-tauri: branchement .with_context_guard(...) au composition root
  + 3 tests de non-régression (round-trip memory read/write, context read,
  symétrie d'erreur typée) dans mcp_serve_peer_tests.

Tests QA verts : cargo test -p app-tauri (0 failed), build 0 warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:36:22 +02:00
parent 6969dc7988
commit 181727d851
3 changed files with 268 additions and 6 deletions

View File

@ -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<dyn domain::conversation::ConversationRegistry>;
// Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en
// `Arc<dyn FileGuard>` 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<dyn domain::fileguard::FileGuard>;
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<dyn Clock>,
)),
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<dyn RecordTurnProvider>,
Arc::clone(&clock) as Arc<dyn Clock>,
),
)
// 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<HashMap<String, String>>,
}
#[async_trait]
impl MemoryStore for FakeMemory {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
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<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, 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<OrchestratorService>, Arc<FakeMemory>) {
let memory = Arc::new(FakeMemory::default());
let file_guard =
Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
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<dyn MemoryStore>,
)),
write_memory: Arc::new(WriteMemory::new(
Arc::clone(&file_guard),
Arc::clone(&memory) as Arc<dyn MemoryStore>,
)),
});
// 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)]

View File

@ -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,

View File

@ -12,5 +12,6 @@ pub use context_guard::{
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
RecordTurnProvider,
};