Corrige le wedge survenant lorsqu'un agent délégué ne rappelle pas idea_reply :
le rendez-vous ask_agent ne reste plus bloqué indéfiniment.
Lot 1 — durcissement du préambule de délégation :
- input/mod.rs : delegation_preamble renforcé (obligation explicite idea_reply).
- agent/lifecycle.rs : préambule injecté conditionné à mcp_enabled.
Lot 2 — peuplement du prompt_ready_pattern :
- agent/catalogue.rs : .with_prompt_ready_pattern("? for shortcuts") sur le
profil Claude + tests de seed, pour calibrer la détection de grâce.
Lot 3 — backstop timeout serveur :
- mcp/jsonrpc.rs : code d'erreur typé RENDEZVOUS_NO_REPLY (-32001).
- mcp/server.rs : mapping timeout → erreur typée, with_ask_rendezvous_timeout
promu + resolve_ask_rendezvous_timeout (configurable).
- app-tauri/state.rs : lecture env IDEA_ASK_RENDEZVOUS_TIMEOUT_MS.
- tests/mcp_server.rs mis à jour, fix d'un test input flaky.
Propagation overlay (référence des profils) :
- agent/catalogue.rs : overlay_reference_defaults + reference_index + tests.
- store/profile.rs : ReferenceBackfillProfileStore + tests.
- app-tauri/state.rs : wrap au composition root.
- exports mod.rs / lib.rs (application + infrastructure).
Tests réels verts : application 494, app-tauri 235, infrastructure 248,
mcp_server 22/22, 0 échec. Validation e2e LIVE (rebuild AppImage) en attente
avant merge vers develop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
427 lines
15 KiB
Rust
427 lines
15 KiB
Rust
//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port.
|
|
//!
|
|
//! Persists the configured [`AgentProfile`]s in the global IDE store
|
|
//! (ARCHITECTURE §9.2):
|
|
//!
|
|
//! ```text
|
|
//! <app_data_dir>/
|
|
//! └── profiles.json # { version, profiles: [AgentProfile, ...] }
|
|
//! ```
|
|
//!
|
|
//! Each profile item is exactly the declarative profile of CONTEXT §9
|
|
//! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The
|
|
//! existence of `profiles.json` is what marks the first run as *done* (see
|
|
//! [`ProfileStore::is_configured`]).
|
|
//!
|
|
//! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data
|
|
//! directory is resolved by the composition root and injected as a plain path,
|
|
//! and all I/O goes through the [`FileSystem`] port.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use domain::ids::ProfileId;
|
|
use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError};
|
|
use domain::profile::{AgentProfile, EmbedderProfile};
|
|
|
|
/// File name of the profiles store inside the app-data dir.
|
|
const PROFILES_FILE: &str = "profiles.json";
|
|
|
|
/// Current schema version of the profiles file.
|
|
const PROFILES_VERSION: u32 = 1;
|
|
|
|
/// On-disk shape of `profiles.json`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ProfilesDoc {
|
|
/// Schema version.
|
|
version: u32,
|
|
/// All configured profiles.
|
|
profiles: Vec<AgentProfile>,
|
|
}
|
|
|
|
impl Default for ProfilesDoc {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: PROFILES_VERSION,
|
|
profiles: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// JSON-file implementation of the [`ProfileStore`] port.
|
|
///
|
|
/// Cheap to clone (everything is behind `Arc`); built once at the composition
|
|
/// root and shared across use cases.
|
|
#[derive(Clone)]
|
|
pub struct FsProfileStore {
|
|
fs: Arc<dyn FileSystem>,
|
|
app_data_dir: String,
|
|
}
|
|
|
|
impl FsProfileStore {
|
|
/// Builds the store from an injected [`FileSystem`] and the app-data
|
|
/// directory path (resolved by the composition root). The directory is
|
|
/// created lazily on first write.
|
|
#[must_use]
|
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
|
Self {
|
|
fs,
|
|
app_data_dir: app_data_dir.into(),
|
|
}
|
|
}
|
|
|
|
/// Joins the app-data dir with the profiles file name (POSIX separator, valid
|
|
/// on every target — `tokio::fs` accepts `/` on Windows too).
|
|
fn path(&self) -> RemotePath {
|
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
|
RemotePath::new(format!("{base}/{PROFILES_FILE}"))
|
|
}
|
|
|
|
/// Reads and parses the doc, returning an empty default if the file is absent.
|
|
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
|
|
match self.fs.read(&self.path()).await {
|
|
Ok(bytes) => {
|
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
|
}
|
|
Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()),
|
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Writes the doc, ensuring the app-data dir exists first.
|
|
async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> {
|
|
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
|
self.fs
|
|
.create_dir_all(&dir)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
let bytes =
|
|
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
|
self.fs
|
|
.write(&self.path(), &bytes)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FsProfileStore {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok(self.read_doc().await?.profiles)
|
|
}
|
|
|
|
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
|
let mut doc = self.read_doc().await?;
|
|
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
|
|
*slot = profile.clone();
|
|
} else {
|
|
doc.profiles.push(profile.clone());
|
|
}
|
|
self.write_doc(&doc).await
|
|
}
|
|
|
|
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
|
let mut doc = self.read_doc().await?;
|
|
let before = doc.profiles.len();
|
|
doc.profiles.retain(|p| p.id != id);
|
|
if doc.profiles.len() == before {
|
|
return Err(StoreError::NotFound);
|
|
}
|
|
self.write_doc(&doc).await
|
|
}
|
|
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
self.fs
|
|
.exists(&self.path())
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
// Write the current doc back (an empty default when nothing exists),
|
|
// which materialises `profiles.json` and records first-run completion.
|
|
let doc = self.read_doc().await?;
|
|
self.write_doc(&doc).await
|
|
}
|
|
}
|
|
|
|
/// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal*
|
|
/// reference defaults that a stored profile is missing, without ever persisting
|
|
/// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles
|
|
/// saved before that field existed).
|
|
///
|
|
/// Only [`list`](ProfileStore::list) is transformed — each returned profile is run
|
|
/// through [`application::overlay_reference_defaults`] (pure, allowlisted, never
|
|
/// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/
|
|
/// `mark_configured` **delegate verbatim** so persistence and first-run detection are
|
|
/// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the
|
|
/// composition root, before injecting the store into use cases and the orchestrator,
|
|
/// so every reader (including direct `.list()` calls) sees the overlaid reads.
|
|
pub struct ReferenceBackfillProfileStore {
|
|
inner: Arc<dyn ProfileStore>,
|
|
}
|
|
|
|
impl ReferenceBackfillProfileStore {
|
|
/// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through.
|
|
#[must_use]
|
|
pub fn new(inner: Arc<dyn ProfileStore>) -> Self {
|
|
Self { inner }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for ReferenceBackfillProfileStore {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok(self
|
|
.inner
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.map(|p| application::overlay_reference_defaults(&p))
|
|
.collect())
|
|
}
|
|
|
|
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
|
self.inner.save(profile).await
|
|
}
|
|
|
|
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
|
self.inner.delete(id).await
|
|
}
|
|
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
self.inner.is_configured().await
|
|
}
|
|
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
self.inner.mark_configured().await
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// File name of the embedder-profiles store inside the app-data dir.
|
|
const EMBEDDER_FILE: &str = "embedder.json";
|
|
|
|
/// Current schema version of the embedder-profiles file.
|
|
const EMBEDDER_VERSION: u32 = 1;
|
|
|
|
/// On-disk shape of `embedder.json` (LOT C, §14.5.3), mirroring [`ProfilesDoc`].
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct EmbedderDoc {
|
|
/// Schema version.
|
|
version: u32,
|
|
/// All configured embedder profiles.
|
|
profiles: Vec<EmbedderProfile>,
|
|
}
|
|
|
|
impl Default for EmbedderDoc {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: EMBEDDER_VERSION,
|
|
profiles: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// JSON-file store for declarative [`EmbedderProfile`]s (LOT C, étage 2 vectoriel),
|
|
/// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir,
|
|
/// mirroring `profiles.json`.
|
|
///
|
|
/// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]).
|
|
/// The inherent `list`/`save`/`delete` methods are kept so the composition root can
|
|
/// load the configured profile *before* type-erasing to `Arc<dyn EmbedderProfileStore>`
|
|
/// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply
|
|
/// delegates to them.
|
|
///
|
|
/// Cheap to clone (everything behind `Arc`).
|
|
#[derive(Clone)]
|
|
pub struct FsEmbedderProfileStore {
|
|
fs: Arc<dyn FileSystem>,
|
|
app_data_dir: String,
|
|
}
|
|
|
|
impl FsEmbedderProfileStore {
|
|
/// Builds the store from an injected [`FileSystem`] and the app-data dir.
|
|
#[must_use]
|
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
|
Self {
|
|
fs,
|
|
app_data_dir: app_data_dir.into(),
|
|
}
|
|
}
|
|
|
|
/// `<app-data-dir>/embedder.json`.
|
|
fn path(&self) -> RemotePath {
|
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
|
RemotePath::new(format!("{base}/{EMBEDDER_FILE}"))
|
|
}
|
|
|
|
async fn read_doc(&self) -> Result<EmbedderDoc, StoreError> {
|
|
match self.fs.read(&self.path()).await {
|
|
Ok(bytes) => {
|
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
|
}
|
|
Err(domain::ports::FsError::NotFound(_)) => Ok(EmbedderDoc::default()),
|
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn write_doc(&self, doc: &EmbedderDoc) -> Result<(), StoreError> {
|
|
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
|
self.fs
|
|
.create_dir_all(&dir)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
let bytes =
|
|
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
|
self.fs
|
|
.write(&self.path(), &bytes)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
|
|
/// Lists all configured embedder profiles (empty when `embedder.json` is
|
|
/// absent — the default `none` posture).
|
|
///
|
|
/// # Errors
|
|
/// [`StoreError`] on an I/O or deserialisation failure.
|
|
pub async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
|
Ok(self.read_doc().await?.profiles)
|
|
}
|
|
|
|
/// Saves (creates or replaces by id) an embedder profile.
|
|
///
|
|
/// # Errors
|
|
/// [`StoreError`] on failure.
|
|
pub async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
|
let mut doc = self.read_doc().await?;
|
|
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
|
|
*slot = profile.clone();
|
|
} else {
|
|
doc.profiles.push(profile.clone());
|
|
}
|
|
self.write_doc(&doc).await
|
|
}
|
|
|
|
/// Deletes an embedder profile by id.
|
|
///
|
|
/// # Errors
|
|
/// [`StoreError::NotFound`] if no profile carries that id.
|
|
pub async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
|
let mut doc = self.read_doc().await?;
|
|
let before = doc.profiles.len();
|
|
doc.profiles.retain(|p| p.id != id);
|
|
if doc.profiles.len() == before {
|
|
return Err(StoreError::NotFound);
|
|
}
|
|
self.write_doc(&doc).await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EmbedderProfileStore for FsEmbedderProfileStore {
|
|
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
|
FsEmbedderProfileStore::list(self).await
|
|
}
|
|
|
|
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
|
FsEmbedderProfileStore::save(self, profile).await
|
|
}
|
|
|
|
async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
|
FsEmbedderProfileStore::delete(self, id).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod backfill_tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
|
|
/// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove
|
|
/// the decorator transforms ONLY `list` and delegates the rest verbatim.
|
|
#[derive(Default)]
|
|
struct FakeStore {
|
|
profiles: Mutex<Vec<AgentProfile>>,
|
|
saved: Mutex<Vec<AgentProfile>>,
|
|
deleted: Mutex<Vec<ProfileId>>,
|
|
is_configured_calls: Mutex<u32>,
|
|
mark_configured_calls: Mutex<u32>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeStore {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok(self.profiles.lock().unwrap().clone())
|
|
}
|
|
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
|
self.saved.lock().unwrap().push(profile.clone());
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
|
self.deleted.lock().unwrap().push(id);
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
*self.is_configured_calls.lock().unwrap() += 1;
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
*self.mark_configured_calls.lock().unwrap() += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Claude reference profile with the internal field cleared — the persisted-before
|
|
/// shape the overlay backfills.
|
|
fn claude_without_pattern() -> AgentProfile {
|
|
let id = application::reference_profile_id("claude");
|
|
let mut p = application::reference_profiles()
|
|
.into_iter()
|
|
.find(|p| p.id == id)
|
|
.expect("claude reference exists");
|
|
p.prompt_ready_pattern = None;
|
|
p
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_overlays_reference_defaults() {
|
|
let fake = Arc::new(FakeStore::default());
|
|
fake.profiles.lock().unwrap().push(claude_without_pattern());
|
|
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
|
|
|
let listed = store.list().await.expect("list ok");
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(
|
|
listed[0].prompt_ready_pattern.as_deref(),
|
|
Some("? for shortcuts"),
|
|
"a stored Claude profile WITHOUT the pattern is backfilled on read"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_delete_and_config_delegate_verbatim() {
|
|
let fake = Arc::new(FakeStore::default());
|
|
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
|
|
|
let p = claude_without_pattern();
|
|
store.save(&p).await.expect("save ok");
|
|
store.delete(p.id).await.expect("delete ok");
|
|
assert!(store.is_configured().await.expect("is_configured ok"));
|
|
store.mark_configured().await.expect("mark_configured ok");
|
|
|
|
// save passes the profile through UNCHANGED (no overlay on the write path).
|
|
assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]);
|
|
assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]);
|
|
assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1);
|
|
assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1);
|
|
}
|
|
}
|