feat(infra,app): store FS live-state + use cases (LS2)
Adapter d'infrastructure et use cases applicatifs du live-state agent. - `FsLiveStateStore` : persistance fichier avec écriture atomique (write-temp + rename) pour éviter tout snapshot partiel/corrompu. - Use cases `UpdateLiveState` / `GetLiveStateLean` + DTO « lean » (vue allégée, bornée pour l'injection/affichage). - Rétention bornée côté store (TTL + max_n) au-dessus des invariants domaine (keyed last-writer-wins, prune). - Garde-fou versionné : `.gitignore` exclut `.ideai/live-state.json` (snapshot runtime reconstruit, non versionné — contrairement à `.ideai/memory/`). cargo test -p infrastructure -p application : 0 échec (dont 5 tests live_state_store) ; cargo fmt --all --check : exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -72,8 +72,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
128
crates/infrastructure/src/store/live_state.rs
Normal file
128
crates/infrastructure/src/store/live_state.rs
Normal file
@ -0,0 +1,128 @@
|
||||
//! [`FsLiveStateStore`] — l'adapter `tokio::fs` du port [`LiveStateStore`]
|
||||
//! (programme live-state, lot LS2).
|
||||
//!
|
||||
//! Le **live-state** est la projection volatile « qui fait quoi en ce moment » :
|
||||
//! une entrée [`LiveEntry`](domain::live_state::LiveEntry) par agent, sémantique
|
||||
//! *keyed last-writer-wins* (jamais un journal). On en garde **un seul** fichier
|
||||
//! par projet, à la racine du `.ideai/` :
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/.ideai/live-state.json
|
||||
//! ```
|
||||
//!
|
||||
//! Le format est le JSON camelCase de [`LiveState`](domain::live_state::LiveState)
|
||||
//! (dérivé serde du domaine), lisible à l'œil.
|
||||
//!
|
||||
//! ## Robustesse
|
||||
//!
|
||||
//! - **Écriture atomique** : on écrit dans `live-state.json.tmp` puis on `rename`
|
||||
//! — un lecteur ne voit jamais de fichier à moitié écrit (le `rename` est
|
||||
//! atomique sur le FS). Même pattern que [`FsHandoffStore`](crate::FsHandoffStore).
|
||||
//! - **Fichier absent** ⇒ [`LiveState`] vide (jamais une erreur) : c'est l'état
|
||||
//! normal d'un projet qui n'a encore rien publié.
|
||||
//! - **Fichier présent mais illisible** (JSON corrompu/partiel) ⇒
|
||||
//! [`StoreError::Serialization`]. Choix **aligné** sur les autres stores FS
|
||||
//! (`FsHandoffStore`, l'`index.json` de `FsSkillStore`) : on ne panique jamais,
|
||||
//! mais on ne masque pas non plus une corruption — l'écriture atomique rend ce
|
||||
//! cas très improbable, et le fichier étant runtime/non-versionné, le supprimer
|
||||
//! suffit à repartir d'un état vide.
|
||||
//!
|
||||
//! Le project root est fourni au constructeur (le port [`LiveStateStore`] n'a pas
|
||||
//! de paramètre `root` par appel, contrairement à `SkillStore`/`MemoryStore`) ;
|
||||
//! une instance sert donc **un** projet.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::live_state::{LiveEntry, LiveState};
|
||||
use domain::ports::{LiveStateStore, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// Le dossier `.ideai/` dans une racine de projet.
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
/// Nom du fichier de live-state, par projet.
|
||||
const LIVE_STATE_FILE: &str = "live-state.json";
|
||||
|
||||
/// Nom du fichier temporaire d'écriture atomique (renommé sur `live-state.json`).
|
||||
const LIVE_STATE_TMP_FILE: &str = "live-state.json.tmp";
|
||||
|
||||
/// Adapter `tokio::fs` du store de live-state, un `live-state.json` par projet.
|
||||
///
|
||||
/// Même convention de construction que [`FsHandoffStore`](crate::FsHandoffStore) :
|
||||
/// le **project root** est fourni au constructeur, la base `<root>/.ideai` en
|
||||
/// dérive, et le fichier est créé paresseusement à la première écriture.
|
||||
pub struct FsLiveStateStore {
|
||||
/// Racine `<project_root>/.ideai`.
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FsLiveStateStore {
|
||||
/// Construit l'adapter à partir du **project root**.
|
||||
#[must_use]
|
||||
pub fn new(root: &ProjectPath) -> Self {
|
||||
let dir = PathBuf::from(root.as_str()).join(IDEAI_DIR);
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/live-state.json` — le fichier cible.
|
||||
fn path(&self) -> PathBuf {
|
||||
self.dir.join(LIVE_STATE_FILE)
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/live-state.json.tmp` — le tmp d'écriture atomique.
|
||||
fn tmp_path(&self) -> PathBuf {
|
||||
self.dir.join(LIVE_STATE_TMP_FILE)
|
||||
}
|
||||
|
||||
/// Lit l'état courant : fichier absent ⇒ état vide ; JSON corrompu ⇒ erreur.
|
||||
async fn read_state(&self) -> Result<LiveState, StoreError> {
|
||||
match tokio::fs::read(self.path()).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
// Absent ⇒ état vide (jamais une erreur).
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LiveState::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Réécrit l'état **atomiquement** : tmp puis `rename` sur la cible.
|
||||
async fn write_state(&self, state: &LiveState) -> Result<(), StoreError> {
|
||||
tokio::fs::create_dir_all(&self.dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
|
||||
let bytes = serde_json::to_vec_pretty(state)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
|
||||
let tmp = self.tmp_path();
|
||||
tokio::fs::write(&tmp, &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
tokio::fs::rename(&tmp, self.path())
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LiveStateStore for FsLiveStateStore {
|
||||
async fn load(&self) -> Result<LiveState, StoreError> {
|
||||
self.read_state().await
|
||||
}
|
||||
|
||||
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
|
||||
let mut state = self.read_state().await?;
|
||||
state.upsert(entry);
|
||||
self.write_state(&state).await
|
||||
}
|
||||
|
||||
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
|
||||
let mut state = self.read_state().await?;
|
||||
state.prune(now_ms, ttl_ms, max_n);
|
||||
self.write_state(&state).await
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
mod context;
|
||||
mod embedder;
|
||||
mod live_state;
|
||||
mod memory;
|
||||
mod permission;
|
||||
mod profile;
|
||||
@ -24,6 +25,7 @@ pub use embedder::{
|
||||
HashEmbedder, OnnxModelInfo, StubEmbedder, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
pub use live_state::FsLiveStateStore;
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
|
||||
Reference in New Issue
Block a user