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>
177 lines
6.3 KiB
Rust
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()))
|
|
}
|
|
}
|