Files
IdeA/crates/application/src/orchestrator/context_guard.rs
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

840 lines
29 KiB
Rust

//! FileGuard-mediated context & memory use cases (cadrage C7).
//!
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
//! the right lease (shared read / exclusive write) for the requesting
//! [`ConversationParty`], then delegates to the existing store ports.
//!
//! ## Single-writer global context
//!
//! The global project context is single-writer: only the orchestrator
//! ([`ConversationParty::User`]) may write it directly. A project agent that
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
//! directly under a write-lease.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
//! scope here.
use std::sync::Arc;
use domain::conversation::ConversationParty;
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, Project};
use crate::error::AppError;
/// Convention filename of the project's global context at the project root.
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
const PROPOSALS_DIR: &str = ".ideai/proposals";
/// Joins a project root with a POSIX-relative segment (valid on every target).
fn join_root(project: &Project, rel: &str) -> RemotePath {
let base = project.root.as_str().trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{rel}"))
}
/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
contexts: &Arc<dyn AgentContextStore>,
project: &Project,
name: &str,
) -> Result<AgentId, AppError> {
let manifest = contexts.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| e.name.eq_ignore_ascii_case(name))
.map(|e| e.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
}
/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
///
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
pub struct ReadContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
}
/// Input for [`ReadContext`].
pub struct ReadContextInput {
/// The project to read within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The reading party (drives the read-lease holder identity).
pub requester: ConversationParty,
}
impl ReadContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
) -> Self {
Self {
guard,
contexts,
fs,
}
}
/// Reads the requested context, returning its Markdown body.
///
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
let ReadContextInput {
project,
target,
requester,
} = input;
match target {
None => {
// Global project context: shared read-lease, then read the root file.
let _lease = self
.guard
.acquire_read(requester, GuardedResource::ProjectContext)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
}
Some(name) => {
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
Ok(self.contexts.read_context(&project, &agent).await?)
}
}
}
}
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
/// **global** project context by a non-orchestrator: the guard returns
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
pub struct ProposeContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
}
/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposeOutcome {
/// The content was written directly (agent context, or global by the orchestrator).
Written,
/// A proposal was materialised at the given `.ideai/proposals/…` path (a
/// non-orchestrator targeting the single-writer global context).
Proposed {
/// The proposal file's path.
path: String,
},
}
/// Input for [`ProposeContext`].
pub struct ProposeContextInput {
/// The project to write within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The proposed Markdown body.
pub content: String,
/// The proposing party.
pub requester: ConversationParty,
}
impl ProposeContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
guard,
contexts,
fs,
clock,
}
}
/// Applies the proposal: direct write under a write-lease, or a materialised
/// proposal when the guard forbids a direct global-context write.
///
/// # Errors
/// [`AppError`] when the agent does not exist or the store/fs fails.
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
let ProposeContextInput {
project,
target,
content,
requester,
} = input;
match target {
Some(name) => {
// Per-agent context: direct write under an exclusive write-lease.
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
self.contexts
.write_context(&project, &agent, &MarkdownDoc::new(content))
.await?;
Ok(ProposeOutcome::Written)
}
None => {
// Global project context: single-writer. Try to acquire the write
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
match self
.guard
.acquire_write(requester, GuardedResource::ProjectContext)
.await
{
Ok(_lease) => {
let path = join_root(&project, PROJECT_CONTEXT_FILE);
self.fs.write(&path, content.as_bytes()).await?;
Ok(ProposeOutcome::Written)
}
Err(GuardError::Forbidden) => {
let path = self.file_proposal(&project, requester, &content).await?;
Ok(ProposeOutcome::Proposed { path })
}
Err(other) => Err(map_guard_err(other)),
}
}
}
}
/// Materialises a rejected global-context change as a proposal file under
/// `.ideai/proposals/<who>-<ts>.md`, returning its path.
async fn file_proposal(
&self,
project: &Project,
who: ConversationParty,
content: &str,
) -> Result<String, AppError> {
let who_label = match who {
ConversationParty::User => "orchestrator".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_string(),
};
let ts = self.clock.now_millis();
let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
let dir = join_root(project, PROPOSALS_DIR);
self.fs.create_dir_all(&dir).await?;
let path = join_root(project, &rel);
self.fs.write(&path, content.as_bytes()).await?;
Ok(path.as_str().to_owned())
}
}
/// Reads project memory under a shared read-lease.
///
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
pub struct ReadMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
/// The project to read within.
pub project: Project,
/// Target note slug; `None` ⇒ the aggregated index.
pub slug: Option<String>,
/// The reading party.
pub requester: ConversationParty,
}
impl ReadMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Reads the requested memory, returning its Markdown content.
///
/// # Errors
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
/// is [`AppError::Invalid`].
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
let ReadMemoryInput {
project,
slug,
requester,
} = input;
match slug {
Some(raw) => {
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let note = self.memory.get(&project.root, &slug).await?;
Ok(note.body.into_string())
}
None => {
// The aggregated index is project-shared; read it as a rendered list.
let entries = self.memory.read_index(&project.root).await?;
let lines: Vec<String> = entries
.into_iter()
.map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
.collect();
Ok(lines.join("\n"))
}
}
}
}
/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
pub struct WriteMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`WriteMemory`].
pub struct WriteMemoryInput {
/// The project to write within.
pub project: Project,
/// Target note slug.
pub slug: String,
/// The Markdown body to store.
pub content: String,
/// The writing party.
pub requester: ConversationParty,
}
impl WriteMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Writes the note under a write-lease.
///
/// # Errors
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
/// failure.
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
let WriteMemoryInput {
project,
slug,
content,
requester,
} = input;
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: format!("memory note {slug}"),
r#type: MemoryType::Project,
};
let note = Memory::new(frontmatter, MarkdownDoc::new(content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memory.save(&project.root, &note).await?;
Ok(())
}
}
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
/// blocking adapter never returns, but is mapped for completeness.
fn map_guard_err(e: GuardError) -> AppError {
match e {
GuardError::Forbidden => AppError::Invalid(
"writing the global project context is reserved to the orchestrator; propose instead"
.to_owned(),
),
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
use std::collections::HashMap;
use std::sync::{Arc as StdArc, Mutex};
use std::time::Duration;
use tokio::sync::RwLock;
fn project() -> Project {
Project::new(
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/demo").unwrap(),
RemoteRef::local(),
0,
)
.unwrap()
}
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
/// `RwLock` per resource + the single-writer rule), so the application crate
/// stays free of any dependency on infrastructure (no dependency cycle).
#[derive(Default)]
struct TestGuard {
locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
}
impl TestGuard {
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
self.locks
.lock()
.unwrap()
.entry(res.clone())
.or_insert_with(|| StdArc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for TestGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
let lock = self.lock_for(&res);
Ok(ReadLease::new(Box::new(lock.read_owned().await)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
}
}
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
// ---- Fakes -----------------------------------------------------------
#[derive(Default)]
struct FakeFs {
files: Mutex<HashMap<String, Vec<u8>>>,
dirs: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.files
.lock()
.unwrap()
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files
.lock()
.unwrap()
.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.dirs.lock().unwrap().push(path.as_str().to_owned());
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
struct FakeContexts {
manifest: AgentManifest,
contexts: Mutex<HashMap<AgentId, String>>,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
self.contexts
.lock()
.unwrap()
.get(agent)
.cloned()
.map(MarkdownDoc::new)
.ok_or(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
self.contexts
.lock()
.unwrap()
.insert(*agent, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[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<domain::memory::MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
42
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
let mut contexts = HashMap::new();
contexts.insert(agent, body.to_owned());
Arc::new(FakeContexts {
manifest: AgentManifest {
version: 1,
entries: vec![ManifestEntry {
agent_id: agent,
name: name.to_owned(),
md_path: "agents/x.md".to_owned(),
profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
template_id: None,
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
}],
},
contexts: Mutex::new(contexts),
})
}
// ---- Tests -----------------------------------------------------------
#[tokio::test]
async fn read_agent_context_returns_body() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let uc = ReadContext::new(
guard(),
contexts_with("Dev", agent, "# hello"),
Arc::new(FakeFs::default()),
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: Some("dev".to_owned()), // case-insensitive
requester: agent_party(1),
})
.await
.unwrap();
assert_eq!(md.as_str(), "# hello");
}
#[tokio::test]
async fn read_global_context_reads_root_file() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
let uc = ReadContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: None,
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(md.as_str(), "# project");
}
#[tokio::test]
async fn concurrent_reads_do_not_block_each_other() {
// Two readers on the same global context, held at once. If the read-lease
// were exclusive this would deadlock; a bounded timeout proves it does not.
let guard = guard();
let r1 = guard
.acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
.await
.unwrap();
let r2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
)
.await
.expect("a second reader must not block")
.unwrap();
drop((r1, r2));
}
#[tokio::test]
async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# hijack".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
// It is a *proposal*, not a write: the live context is untouched.
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# original",
"the live global context must NOT be overwritten by a proposal"
);
// The proposal landed under .ideai/proposals/.
let files = fs.files.lock().unwrap();
assert!(files
.keys()
.any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
}
#[tokio::test]
async fn orchestrator_writes_global_context_directly() {
let fs = Arc::new(FakeFs::default());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# new".to_owned(),
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn propose_agent_context_writes_directly() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let contexts = contexts_with("Dev", agent, "# old");
let uc = ProposeContext::new(
guard(),
Arc::clone(&contexts),
Arc::new(FakeFs::default()),
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: Some("Dev".to_owned()),
content: "# new body".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
contexts
.read_context(&project(), &agent)
.await
.unwrap()
.as_str(),
"# new body"
);
}
#[tokio::test]
async fn write_then_read_memory_round_trips_under_guard() {
let memory = Arc::new(FakeMemory::default());
let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
writer
.execute(WriteMemoryInput {
project: project(),
slug: "note-a".to_owned(),
content: "body".to_owned(),
requester: agent_party(2),
})
.await
.unwrap();
let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
let body = reader
.execute(ReadMemoryInput {
project: project(),
slug: Some("note-a".to_owned()),
requester: agent_party(2),
})
.await
.unwrap();
assert_eq!(body, "body");
}
#[tokio::test]
async fn writes_to_same_memory_note_serialise() {
// Two write leases on the same note must not overlap (exclusive writer).
let guard = guard();
let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
let w1 = guard
.acquire_write(agent_party(1), slug.clone())
.await
.unwrap();
// While w1 is held, a second writer blocks; it only succeeds after release.
let blocked = tokio::time::timeout(
Duration::from_millis(100),
guard.acquire_write(agent_party(2), slug.clone()),
)
.await;
assert!(
blocked.is_err(),
"a second writer must block while w1 holds"
);
drop(w1);
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(agent_party(2), slug),
)
.await
.expect("w2 acquires after w1 releases")
.unwrap();
drop(w2);
}
}