merge(orchestrator): intègre le câblage du ContextGuard dans develop
Correctif fix/wire-context-guard : branchement .with_context_guard(...) au composition root + re-exports application + 3 tests de non-régression. Tests QA verts (cargo test -p app-tauri : 0 failed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -12,5 +12,6 @@ pub use context_guard::{
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use service::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
|
||||
RecordTurnProvider,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user