Files
IdeA/crates/infrastructure/src/store/context.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

177 lines
6.3 KiB
Rust

//! [`IdeaiContextStore`] — file implementation of the [`AgentContextStore`] port
//! (ARCHITECTURE §5, §9.1).
//!
//! Persists, **inside the project root**, the agent contexts and the manifest
//! that together make a project's agents travel with the code:
//!
//! ```text
//! <project_root>/
//! └── .ideai/
//! ├── agents.json # AgentManifest { version, agents: [ManifestEntry, ...] }
//! └── agents/
//! ├── backend-dev.md # an agent's context (md_path = "agents/backend-dev.md")
//! └── ...
//! ```
//!
//! All I/O goes through the [`FileSystem`] port (here the `RemoteHost`'s
//! filesystem — [`crate::LocalFileSystem`] locally, an SFTP/WSL one later), so the
//! store is **location-neutral**: the very same adapter serves a local project or
//! a project hosted over SSH/WSL (Liskov, ARCHITECTURE §1.2).
//!
//! `md_path` values are interpreted **relative to `.ideai/`** (so a manifest
//! `md_path` of `agents/backend-dev.md` maps to
//! `<root>/.ideai/agents/backend-dev.md`), matching the documented schema.
use std::sync::Arc;
use async_trait::async_trait;
use domain::agent::AgentManifest;
use domain::ids::AgentId;
use domain::markdown::MarkdownDoc;
use domain::ports::{AgentContextStore, FileSystem, FsError, RemotePath, StoreError};
use domain::project::{Project, ProjectPath};
/// The `.ideai/` directory name inside a project root.
const IDEAI_DIR: &str = ".ideai";
/// The agent-manifest file name inside `.ideai/`.
const AGENTS_FILE: &str = "agents.json";
/// Current schema version written into a freshly-created manifest.
const MANIFEST_VERSION: u32 = 1;
/// File-backed [`AgentContextStore`], composing a [`FileSystem`] port.
///
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
/// one per resolved `RemoteHost` and shares it across the agent use cases.
#[derive(Clone)]
pub struct IdeaiContextStore {
fs: Arc<dyn FileSystem>,
}
impl IdeaiContextStore {
/// Builds the store from an injected [`FileSystem`] (the project's
/// `RemoteHost` filesystem).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Joins a project root with a relative segment using a POSIX separator
/// (valid on every target; `tokio::fs` accepts `/` on Windows too).
fn join(root: &ProjectPath, rel: &str) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
/// Absolute path of the manifest file for a project.
fn manifest_path(project: &Project) -> RemotePath {
RemotePath::new(Self::join(
&project.root,
&format!("{IDEAI_DIR}/{AGENTS_FILE}"),
))
}
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)
/// `md_path`.
fn context_path(project: &Project, md_path: &str) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{md_path}")))
}
/// Resolves the `md_path` of an agent from the manifest, or
/// [`StoreError::NotFound`] if the agent is unknown to this project.
async fn md_path_of(&self, project: &Project, agent: &AgentId) -> Result<String, StoreError> {
let manifest = self.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| &e.agent_id == agent)
.map(|e| e.md_path)
.ok_or(StoreError::NotFound)
}
/// Ensures the directory holding `path` exists (creates `.ideai/` and any
/// nested `agents/` parent before a write).
async fn ensure_parent(&self, path: &RemotePath) -> Result<(), StoreError> {
let raw = path.as_str();
let dir = match raw.rfind(['/', '\\']) {
Some(idx) => &raw[..idx],
None => return Ok(()),
};
self.fs
.create_dir_all(&RemotePath::new(dir.to_owned()))
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl AgentContextStore for IdeaiContextStore {
async fn read_context(
&self,
project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
match self.fs.read(&path).await {
Ok(bytes) => {
let content = String::from_utf8(bytes)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
Ok(MarkdownDoc::new(content))
}
Err(FsError::NotFound(_)) => Err(StoreError::NotFound),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn write_context(
&self,
project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
self.ensure_parent(&path).await?;
self.fs
.write(&path, md.as_str().as_bytes())
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError> {
let path = Self::manifest_path(project);
match self.fs.read(&path).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
// No manifest yet: a project simply has no agents — return the empty
// default rather than erroring (mirrors the project-store policy).
Err(FsError::NotFound(_)) => Ok(AgentManifest {
version: MANIFEST_VERSION,
entries: Vec::new(),
orchestrator: None,
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn save_manifest(
&self,
project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
let path = Self::manifest_path(project);
self.ensure_parent(&path).await?;
let mut bytes = serde_json::to_vec_pretty(manifest)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
bytes.push(b'\n');
self.fs
.write(&path, &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}