merge(context): intègre fix-context-read-canonical-path — idea_context_read pointe .ideai/CONTEXT.md (vert QA)
This commit is contained in:
@ -29,14 +29,16 @@ use domain::conversation::ConversationParty;
|
|||||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
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 domain::{AgentId, DomainEvent, Project};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::error::AppError;
|
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.
|
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
|
||||||
const PROPOSALS_DIR: &str = ".ideai/proposals";
|
const PROPOSALS_DIR: &str = ".ideai/proposals";
|
||||||
|
|
||||||
@ -46,6 +48,21 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
|||||||
RemotePath::new(format!("{base}/{rel}"))
|
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<dyn FileSystem>,
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<Vec<u8>, 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 {
|
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(bytes);
|
hasher.update(bytes);
|
||||||
@ -126,14 +143,13 @@ impl ReadContext {
|
|||||||
} = input;
|
} = input;
|
||||||
match target {
|
match target {
|
||||||
None => {
|
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
|
let _lease = self
|
||||||
.guard
|
.guard
|
||||||
.acquire_read(requester, GuardedResource::ProjectContext)
|
.acquire_read(requester, GuardedResource::ProjectContext)
|
||||||
.await
|
.await
|
||||||
.map_err(map_guard_err)?;
|
.map_err(map_guard_err)?;
|
||||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
let bytes = read_project_context_bytes(&self.fs, &project).await?;
|
||||||
let bytes = self.fs.read(&path).await?;
|
|
||||||
let version = hex_sha256(&bytes);
|
let version = hex_sha256(&bytes);
|
||||||
let text =
|
let text =
|
||||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
@ -236,8 +252,8 @@ impl UpdateProjectContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_guard_err)?;
|
.map_err(map_guard_err)?;
|
||||||
|
|
||||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
let path = project_context_path(&project);
|
||||||
let current_bytes = self.fs.read(&path).await?;
|
let current_bytes = read_project_context_bytes(&self.fs, &project).await?;
|
||||||
let current_version = hex_sha256(¤t_bytes);
|
let current_version = hex_sha256(¤t_bytes);
|
||||||
if let Some(expected) = if_match {
|
if let Some(expected) = if_match {
|
||||||
if expected != current_version {
|
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?;
|
self.fs.write(&path, content.as_bytes()).await?;
|
||||||
let new_version = hex_sha256(content.as_bytes());
|
let new_version = hex_sha256(content.as_bytes());
|
||||||
self.events.publish(DomainEvent::ProjectContextUpdated {
|
self.events.publish(DomainEvent::ProjectContextUpdated {
|
||||||
@ -354,7 +373,10 @@ impl ProposeContext {
|
|||||||
.acquire_write(requester, resource)
|
.acquire_write(requester, resource)
|
||||||
.await
|
.await
|
||||||
.map_err(map_guard_err)?;
|
.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?;
|
self.fs.write(&path, content.as_bytes()).await?;
|
||||||
Ok(ProposeOutcome::Written)
|
Ok(ProposeOutcome::Written)
|
||||||
}
|
}
|
||||||
@ -580,6 +602,10 @@ mod tests {
|
|||||||
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_context_file() -> &'static str {
|
||||||
|
"/tmp/demo/.ideai/CONTEXT.md"
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Fakes -----------------------------------------------------------
|
// ---- Fakes -----------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@ -793,16 +819,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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());
|
let fs = Arc::new(FakeFs::default());
|
||||||
fs.files
|
{
|
||||||
.lock()
|
let mut files = fs.files.lock().unwrap();
|
||||||
.unwrap()
|
files.insert(project_context_file().to_owned(), b"# project".to_vec());
|
||||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
|
}
|
||||||
let uc = ReadContext::new(
|
let uc = ReadContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||||
fs,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
);
|
);
|
||||||
let out = uc
|
let out = uc
|
||||||
.execute(ReadContextInput {
|
.execute(ReadContextInput {
|
||||||
@ -814,6 +840,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out.content.as_str(), "# project");
|
assert_eq!(out.content.as_str(), "# project");
|
||||||
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
||||||
|
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -841,7 +868,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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(
|
let uc = ProposeContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
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.
|
// It is a *proposal*, not a write: the live context is untouched.
|
||||||
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
|
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# original",
|
b"# original",
|
||||||
"the live global context must NOT be overwritten by a proposal"
|
"the live global context must NOT be overwritten by a proposal"
|
||||||
);
|
);
|
||||||
@ -891,9 +922,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(outcome, ProposeOutcome::Written);
|
assert_eq!(outcome, ProposeOutcome::Written);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# new"
|
b"# new"
|
||||||
);
|
);
|
||||||
|
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -903,7 +939,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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 bus = Arc::new(SpyBus::default());
|
||||||
let uc = UpdateProjectContext::new(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
@ -925,9 +961,14 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# new"
|
b"# new"
|
||||||
);
|
);
|
||||||
|
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -936,7 +977,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||||
@ -957,7 +998,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(err.code(), "INVALID");
|
assert_eq!(err.code(), "INVALID");
|
||||||
let files = fs.files.lock().unwrap();
|
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!(
|
assert!(
|
||||||
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
||||||
"strict update must fail loud, not file a proposal"
|
"strict update must fail loud, not file a proposal"
|
||||||
@ -971,7 +1012,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", agent, "x"),
|
contexts_with("Dev", agent, "x"),
|
||||||
@ -992,7 +1033,11 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# current"
|
b"# current"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1004,7 +1049,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", agent, "x"),
|
contexts_with("Dev", agent, "x"),
|
||||||
@ -1025,7 +1070,11 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# new"
|
b"# new"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1037,7 +1086,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
contexts_with("Dev", agent, "x"),
|
contexts_with("Dev", agent, "x"),
|
||||||
@ -1056,7 +1105,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
fs.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(project_context_file())
|
||||||
|
.unwrap(),
|
||||||
b"# latest"
|
b"# latest"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1068,7 +1121,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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 bus = Arc::new(SpyBus::default());
|
||||||
let uc = UpdateProjectContext::new(
|
let uc = UpdateProjectContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
@ -1104,7 +1157,7 @@ mod tests {
|
|||||||
fs.files
|
fs.files
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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 contexts = contexts_with("Dev", agent, "agent body");
|
||||||
let reader = ReadContext::new(
|
let reader = ReadContext::new(
|
||||||
guard(),
|
guard(),
|
||||||
|
|||||||
Reference in New Issue
Block a user