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>
840 lines
29 KiB
Rust
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, ¬e).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);
|
|
}
|
|
}
|