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:
157
crates/infrastructure/tests/live_state_store.rs
Normal file
157
crates/infrastructure/tests/live_state_store.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user