diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index f3c9dd4..03213f3 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -29,14 +29,16 @@ use domain::conversation::ConversationParty; use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource}; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; -use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath}; +use domain::ports::{ + AgentContextStore, Clock, EventBus, FileSystem, FsError, MemoryStore, RemotePath, +}; use domain::{AgentId, DomainEvent, Project}; use sha2::{Digest, Sha256}; use crate::error::AppError; +use crate::project::meta::IDEAI_DIR; +use crate::project::project_context_path; -/// 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"; @@ -46,6 +48,21 @@ fn join_root(project: &Project, rel: &str) -> RemotePath { RemotePath::new(format!("{base}/{rel}")) } +fn project_context_dir(project: &Project) -> RemotePath { + join_root(project, IDEAI_DIR) +} + +async fn read_project_context_bytes( + fs: &Arc, + project: &Project, +) -> Result, AppError> { + match fs.read(&project_context_path(project)).await { + Ok(bytes) => Ok(bytes), + Err(FsError::NotFound(_)) => Ok(Vec::new()), + Err(err) => Err(AppError::FileSystem(err.to_string())), + } +} + pub(crate) fn hex_sha256(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -126,14 +143,13 @@ impl ReadContext { } = input; match target { None => { - // Global project context: shared read-lease, then read the root file. + // Global project context: shared read-lease, then read `.ideai/CONTEXT.md`. 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 bytes = read_project_context_bytes(&self.fs, &project).await?; let version = hex_sha256(&bytes); let text = String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?; @@ -236,8 +252,8 @@ impl UpdateProjectContext { .await .map_err(map_guard_err)?; - let path = join_root(&project, PROJECT_CONTEXT_FILE); - let current_bytes = self.fs.read(&path).await?; + let path = project_context_path(&project); + let current_bytes = read_project_context_bytes(&self.fs, &project).await?; let current_version = hex_sha256(¤t_bytes); if let Some(expected) = if_match { if expected != current_version { @@ -245,6 +261,9 @@ impl UpdateProjectContext { } } + self.fs + .create_dir_all(&project_context_dir(&project)) + .await?; self.fs.write(&path, content.as_bytes()).await?; let new_version = hex_sha256(content.as_bytes()); self.events.publish(DomainEvent::ProjectContextUpdated { @@ -354,7 +373,10 @@ impl ProposeContext { .acquire_write(requester, resource) .await .map_err(map_guard_err)?; - let path = join_root(&project, PROJECT_CONTEXT_FILE); + let path = project_context_path(&project); + self.fs + .create_dir_all(&project_context_dir(&project)) + .await?; self.fs.write(&path, content.as_bytes()).await?; Ok(ProposeOutcome::Written) } @@ -580,6 +602,10 @@ mod tests { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } + fn project_context_file() -> &'static str { + "/tmp/demo/.ideai/CONTEXT.md" + } + // ---- Fakes ----------------------------------------------------------- #[derive(Default)] @@ -793,16 +819,16 @@ mod tests { } #[tokio::test] - async fn read_global_context_reads_root_file() { + async fn read_global_context_reads_canonical_context_file_without_root_claude() { let fs = Arc::new(FakeFs::default()); - fs.files - .lock() - .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec()); + { + let mut files = fs.files.lock().unwrap(); + files.insert(project_context_file().to_owned(), b"# project".to_vec()); + } let uc = ReadContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), - fs, + Arc::clone(&fs) as Arc, ); let out = uc .execute(ReadContextInput { @@ -814,6 +840,7 @@ mod tests { .unwrap(); assert_eq!(out.content.as_str(), "# project"); assert_eq!(out.version, Some(hex_sha256(b"# project"))); + assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md")); } #[tokio::test] @@ -841,7 +868,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec()); + .insert(project_context_file().to_owned(), b"# original".to_vec()); let uc = ProposeContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), @@ -860,7 +887,11 @@ mod tests { // 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(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# original", "the live global context must NOT be overwritten by a proposal" ); @@ -891,9 +922,14 @@ mod tests { .unwrap(); assert_eq!(outcome, ProposeOutcome::Written); assert_eq!( - fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# new" ); + assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md")); } #[tokio::test] @@ -903,7 +939,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + .insert(project_context_file().to_owned(), b"# old".to_vec()); let bus = Arc::new(SpyBus::default()); let uc = UpdateProjectContext::new( guard(), @@ -925,9 +961,14 @@ mod tests { assert_eq!(out.new_version, hex_sha256(b"# new")); assert_eq!( - fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# new" ); + assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md")); } #[tokio::test] @@ -936,7 +977,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + .insert(project_context_file().to_owned(), b"# old".to_vec()); let uc = UpdateProjectContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), @@ -957,7 +998,7 @@ mod tests { assert_eq!(err.code(), "INVALID"); let files = fs.files.lock().unwrap(); - assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old"); + assert_eq!(files.get(project_context_file()).unwrap(), b"# old"); assert!( !files.keys().any(|path| path.contains("/.ideai/proposals/")), "strict update must fail loud, not file a proposal" @@ -971,7 +1012,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec()); + .insert(project_context_file().to_owned(), b"# current".to_vec()); let uc = UpdateProjectContext::new( guard(), contexts_with("Dev", agent, "x"), @@ -992,7 +1033,11 @@ mod tests { assert_eq!(err, AppError::Conflict(hex_sha256(b"# current"))); assert_eq!( - fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# current" ); } @@ -1004,7 +1049,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + .insert(project_context_file().to_owned(), b"# old".to_vec()); let uc = UpdateProjectContext::new( guard(), contexts_with("Dev", agent, "x"), @@ -1025,7 +1070,11 @@ mod tests { assert_eq!(out.new_version, hex_sha256(b"# new")); assert_eq!( - fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# new" ); } @@ -1037,7 +1086,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec()); + .insert(project_context_file().to_owned(), b"# previous".to_vec()); let uc = UpdateProjectContext::new( guard(), contexts_with("Dev", agent, "x"), @@ -1056,7 +1105,11 @@ mod tests { .unwrap(); assert_eq!( - fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + fs.files + .lock() + .unwrap() + .get(project_context_file()) + .unwrap(), b"# latest" ); } @@ -1068,7 +1121,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + .insert(project_context_file().to_owned(), b"# old".to_vec()); let bus = Arc::new(SpyBus::default()); let uc = UpdateProjectContext::new( guard(), @@ -1104,7 +1157,7 @@ mod tests { fs.files .lock() .unwrap() - .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec()); + .insert(project_context_file().to_owned(), b"# first".to_vec()); let contexts = contexts_with("Dev", agent, "agent body"); let reader = ReadContext::new( guard(),