feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -29,8 +29,9 @@ 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, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, DomainEvent, Project};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hex_encode(&hasher.finalize())
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
@ -80,6 +91,14 @@ pub struct ReadContextInput {
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`ReadContext`].
|
||||
pub struct ReadContextOutput {
|
||||
/// The context Markdown.
|
||||
pub content: MarkdownDoc,
|
||||
/// sha256 hex digest of `content`, only for the global project context.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
@ -99,7 +118,7 @@ impl ReadContext {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
@ -115,9 +134,13 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let version = hex_sha256(&bytes);
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
Ok(ReadContextOutput {
|
||||
content: MarkdownDoc::new(text),
|
||||
version: Some(version),
|
||||
})
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
@ -126,12 +149,114 @@ impl ReadContext {
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
Ok(ReadContextOutput {
|
||||
content: self.contexts.read_context(&project, &agent).await?,
|
||||
version: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directly updates the global project context. This is the strict, fail-loud
|
||||
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
|
||||
pub struct UpdateProjectContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectContext`].
|
||||
pub struct UpdateProjectContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// New global project context Markdown.
|
||||
pub content: String,
|
||||
/// Optional expected current version.
|
||||
pub if_match: Option<String>,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateProjectContext`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectContextOutput {
|
||||
/// sha256 hex digest of the newly written content.
|
||||
pub new_version: String,
|
||||
}
|
||||
|
||||
impl UpdateProjectContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
events,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the direct global-context update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
|
||||
/// - [`AppError::Conflict`] when `if_match` does not match current content,
|
||||
/// - [`AppError`] on store/fs failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectContextInput,
|
||||
) -> Result<UpdateProjectContextOutput, AppError> {
|
||||
let UpdateProjectContextInput {
|
||||
project,
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} = input;
|
||||
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
return Err(map_guard_err(GuardError::Forbidden));
|
||||
}
|
||||
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let current_bytes = self.fs.read(&path).await?;
|
||||
let current_version = hex_sha256(¤t_bytes);
|
||||
if let Some(expected) = if_match {
|
||||
if expected != current_version {
|
||||
return Err(AppError::Conflict(current_version));
|
||||
}
|
||||
}
|
||||
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
let new_version = hex_sha256(content.as_bytes());
|
||||
self.events.publish(DomainEvent::ProjectContextUpdated {
|
||||
project_id: project.id,
|
||||
by: requester,
|
||||
at_ms: self.clock.now_millis(),
|
||||
});
|
||||
|
||||
Ok(UpdateProjectContextOutput { new_version })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -393,7 +518,7 @@ mod tests {
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
@ -599,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
@ -619,6 +763,7 @@ mod tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
@ -635,7 +780,7 @@ mod tests {
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
@ -643,7 +788,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
assert_eq!(out.content.as_str(), "# hello");
|
||||
assert_eq!(out.version, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -658,7 +804,7 @@ mod tests {
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
@ -666,7 +812,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
assert_eq!(out.content.as_str(), "# project");
|
||||
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -749,6 +896,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# rejected".to_owned(),
|
||||
if_match: None,
|
||||
requester: agent_party(8),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
|
||||
assert!(
|
||||
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
||||
"strict update must fail loud, not file a proposal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some("stale".to_owned()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# current"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_matching_succeeds() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some(hex_sha256(b"# old")),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_no_if_match_is_last_write_wins() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# latest".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_publishes_project_context_updated_event() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::ProjectContextUpdated {
|
||||
project_id: project().id,
|
||||
by: ConversationParty::agent(agent),
|
||||
at_ms: 42,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_context_and_update_project_context_version_round_trip() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
|
||||
let contexts = contexts_with("Dev", agent, "agent body");
|
||||
let reader = ReadContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
);
|
||||
let updater = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts,
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let version = reader
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.version
|
||||
.unwrap();
|
||||
|
||||
updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# second".to_owned(),
|
||||
if_match: Some(version.clone()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let stale = updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# third".to_owned(),
|
||||
if_match: Some(version),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
|
||||
@ -10,8 +10,9 @@ mod service;
|
||||
pub mod wake;
|
||||
|
||||
pub use context_guard::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
|
||||
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
|
||||
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use rendezvous::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -47,7 +47,8 @@ use crate::error::AppError;
|
||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||
WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
|
||||
pub read_context: Arc<ReadContext>,
|
||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||
pub propose_context: Arc<ProposeContext>,
|
||||
/// Écriture directe stricte du contexte projet global.
|
||||
pub update_project_context: Arc<UpdateProjectContext>,
|
||||
/// Lecture mémoire sous read-lease.
|
||||
pub read_memory: Arc<ReadMemory>,
|
||||
/// Écriture mémoire sous write-lease.
|
||||
@ -1241,6 +1244,14 @@ impl OrchestratorService {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} => {
|
||||
self.update_project_context(project, content, if_match, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -1363,7 +1374,7 @@ impl OrchestratorService {
|
||||
target: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let md = self
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.read_context
|
||||
.execute(ReadContextInput {
|
||||
@ -1372,9 +1383,13 @@ impl OrchestratorService {
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
let mut text = out.content.into_string();
|
||||
if let Some(version) = &out.version {
|
||||
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
|
||||
}
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
reply: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1411,6 +1426,31 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.update` → strict direct write of the global project context.
|
||||
async fn update_project_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
content: String,
|
||||
if_match: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.update_project_context
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project.clone(),
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("wrote project context (version {})", out.new_version),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
/// content is returned inline in the outcome's `reply`.
|
||||
async fn read_memory(
|
||||
@ -2783,6 +2823,7 @@ impl OrchestratorService {
|
||||
description: None,
|
||||
content,
|
||||
scope,
|
||||
kind: domain::SkillKind::Workflow,
|
||||
project_root: project.root.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user