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:
2026-06-21 19:12:46 +02:00
parent 9815af01b1
commit 18401116aa
8 changed files with 645 additions and 6 deletions

View File

@ -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,
};

View 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
}
}

View File

@ -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};

View File

@ -0,0 +1,157 @@
//! LS2 integration tests for [`FsLiveStateStore`] against a **real** temp
//! directory (programme live-state).
//!
//! These lock the durable behaviour an in-memory double cannot prove:
//! - round-trip `upsert`/`load` survives a fresh instance ("restart");
//! - a missing file => empty `LiveState` (never an error);
//! - the write is atomic (no `.tmp` residue left behind);
//! - `upsert` is keyed last-writer-wins, persisted (no duplicate per agent);
//! - `prune` (TTL + max_n) is persisted.
//!
//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur
//! `conversation_log.rs`) — absolute path for `ProjectPath::new`, cleaned on drop.
use std::path::PathBuf;
use domain::live_state::{LiveEntry, WorkStatus};
use domain::ports::LiveStateStore;
use domain::project::ProjectPath;
use domain::AgentId;
use infrastructure::FsLiveStateStore;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-ls2-livestate-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn project_path(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
}
fn file_path(&self) -> PathBuf {
self.0.join(".ideai").join("live-state.json")
}
fn tmp_path(&self) -> PathBuf {
self.0.join(".ideai").join("live-state.json.tmp")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn entry(agent: u128, intent: &str, status: WorkStatus, updated_at_ms: u64) -> LiveEntry {
LiveEntry::new(aid(agent), None, intent, status, None, None, updated_at_ms).unwrap()
}
#[tokio::test]
async fn missing_file_loads_empty_without_error() {
let tmp = TempDir::new();
let store = FsLiveStateStore::new(&tmp.project_path());
let state = store.load().await.unwrap();
assert!(state.entries.is_empty(), "no file ⇒ empty live-state");
// load() must not have created the file.
assert!(!tmp.file_path().exists(), "load does not write");
}
#[tokio::test]
async fn upsert_round_trips_across_a_fresh_instance() {
let tmp = TempDir::new();
{
let store = FsLiveStateStore::new(&tmp.project_path());
store
.upsert(entry(1, "building", WorkStatus::Working, 100))
.await
.unwrap();
}
// A fresh instance on the same root relits the persisted row.
let store = FsLiveStateStore::new(&tmp.project_path());
let state = store.load().await.unwrap();
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].intent, "building");
assert_eq!(state.entries[0].status, WorkStatus::Working);
// Persisted JSON is camelCase.
let raw = std::fs::read_to_string(tmp.file_path()).unwrap();
assert!(raw.contains("\"agentId\""), "camelCase on disk: {raw}");
assert!(raw.contains("\"updatedAtMs\""));
}
#[tokio::test]
async fn write_is_atomic_no_tmp_residue() {
let tmp = TempDir::new();
let store = FsLiveStateStore::new(&tmp.project_path());
store
.upsert(entry(1, "a", WorkStatus::Working, 1))
.await
.unwrap();
store
.upsert(entry(2, "b", WorkStatus::Idle, 2))
.await
.unwrap();
assert!(tmp.file_path().exists(), "final file present");
assert!(
!tmp.tmp_path().exists(),
"tmp file must be renamed away, not left behind"
);
}
#[tokio::test]
async fn upsert_is_last_writer_wins_persisted() {
let tmp = TempDir::new();
let store = FsLiveStateStore::new(&tmp.project_path());
store
.upsert(entry(1, "first", WorkStatus::Working, 1))
.await
.unwrap();
store
.upsert(entry(1, "second", WorkStatus::Blocked, 2))
.await
.unwrap();
let state = store.load().await.unwrap();
assert_eq!(state.entries.len(), 1, "same agent ⇒ one row, no duplicate");
assert_eq!(state.entries[0].intent, "second");
assert_eq!(state.entries[0].status, WorkStatus::Blocked);
}
#[tokio::test]
async fn prune_ttl_and_max_n_persisted() {
let tmp = TempDir::new();
let store = FsLiveStateStore::new(&tmp.project_path());
// Three rows at t=10/20/30; now=50, ttl=35 ⇒ t=10 (age 40>35) dropped,
// t=20 (age 30) and t=30 (age 20) kept.
store
.upsert(entry(1, "old", WorkStatus::Idle, 10))
.await
.unwrap();
store
.upsert(entry(2, "mid", WorkStatus::Working, 20))
.await
.unwrap();
store
.upsert(entry(3, "new", WorkStatus::Working, 30))
.await
.unwrap();
store.prune(50, 35, 100).await.unwrap();
let after_ttl = store.load().await.unwrap();
assert_eq!(after_ttl.entries.len(), 2, "TTL dropped the oldest");
// Now bound to 1 ⇒ keep the most recent (t=30).
store.prune(50, 35, 1).await.unwrap();
let after_cap = store.load().await.unwrap();
assert_eq!(after_cap.entries.len(), 1);
assert_eq!(after_cap.entries[0].updated_at_ms, 30);
}