Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge. - domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id (cache resumable moteur, additif serde default) - infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au lieu de new_random() → id recalculable sans état, identique après restart - application : launch_structured persiste l'id de paire sur la cellule (et l'id moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ; resolve_conversation repli factorisé sur for_pair ; withers layout clone-and- mutate (préservent les champs additifs) - app-tauri : engine_session_id propagé dans les DTO Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3 recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de quota agent (binôme Test interrompu par limite de session). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
11 KiB
Rust
349 lines
11 KiB
Rust
//! R0c tests for [`ReconcileLayouts`].
|
|
//!
|
|
//! At project open, a persisted `layouts.json` may already hold several leaves
|
|
//! pinning the **same** agent id. The use case de-duplicates them through the
|
|
//! pure domain op [`domain::LayoutTree::reconcile_duplicate_agents`] and
|
|
//! persists the doc **only** when something changed (idempotent no-op
|
|
//! otherwise).
|
|
//!
|
|
//! Harness mirrors `tests/snapshot_running_agents.rs` (its twin use case): the
|
|
//! same in-memory `FakeFs` / `FakeStore`. The fake FS additionally counts the
|
|
//! number of `write` **calls** so test 7 can prove that a no-op performs zero
|
|
//! writes (file-count alone cannot, since `layouts.json` already exists).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::layout::Workspace;
|
|
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
|
|
use domain::{
|
|
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
|
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use application::{ReconcileLayouts, ReconcileLayoutsInput};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct FakeFsInner {
|
|
files: HashMap<String, Vec<u8>>,
|
|
dirs: HashSet<String>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeFs {
|
|
inner: Arc<Mutex<FakeFsInner>>,
|
|
/// Count of `write` **calls** (not files). Lets a no-op be asserted even
|
|
/// though the target file already exists.
|
|
write_calls: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl FakeFs {
|
|
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
|
self.inner.lock().unwrap().files.get(path).cloned()
|
|
}
|
|
fn put(&self, path: &str, data: &[u8]) {
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.insert(path.to_owned(), data.to_vec());
|
|
}
|
|
fn write_calls(&self) -> usize {
|
|
self.write_calls.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileSystem for FakeFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.get(path.as_str())
|
|
.cloned()
|
|
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
|
}
|
|
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
|
self.write_calls.fetch_add(1, Ordering::SeqCst);
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.insert(path.as_str().to_owned(), data.to_vec());
|
|
Ok(())
|
|
}
|
|
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
|
let inner = self.inner.lock().unwrap();
|
|
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
|
|
}
|
|
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.dirs
|
|
.insert(path.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeStoreInner {
|
|
projects: Vec<Project>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
|
|
|
#[async_trait]
|
|
impl ProjectStore for FakeStore {
|
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
|
Ok(self.0.lock().unwrap().projects.clone())
|
|
}
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.projects
|
|
.iter()
|
|
.find(|p| p.id == id)
|
|
.cloned()
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().projects.push(project.clone());
|
|
Ok(())
|
|
}
|
|
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
|
Ok(Workspace::default())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ROOT: &str = "/home/me/proj";
|
|
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
|
|
|
|
fn pid(n: u128) -> ProjectId {
|
|
ProjectId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn nid(n: u128) -> NodeId {
|
|
NodeId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn lid(n: u128) -> LayoutId {
|
|
LayoutId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running: bool) -> LeafCell {
|
|
LeafCell {
|
|
id: node,
|
|
session: None,
|
|
agent,
|
|
conversation_id: conv.map(str::to_string),
|
|
engine_session_id: None,
|
|
agent_was_running: running,
|
|
}
|
|
}
|
|
|
|
/// Two leaves of the same agent, both flagged running, under a Row split.
|
|
fn duplicate_tree(agent: AgentId, a: NodeId, b: NodeId) -> LayoutTree {
|
|
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
|
id: nid(1),
|
|
direction: Direction::Row,
|
|
children: vec![
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(a, Some(agent), Some("c-a"), true)),
|
|
weight: 1.0,
|
|
},
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(b, Some(agent), Some("c-b"), true)),
|
|
weight: 1.0,
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
async fn register_project(store: &FakeStore, id: ProjectId) {
|
|
let project = Project::new(
|
|
id,
|
|
"Demo",
|
|
ProjectPath::new(ROOT).unwrap(),
|
|
RemoteRef::Local,
|
|
0,
|
|
)
|
|
.unwrap();
|
|
store.save_project(&project).await.unwrap();
|
|
}
|
|
|
|
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
|
let doc = serde_json::json!({
|
|
"version": 1,
|
|
"activeId": id.to_string(),
|
|
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
|
|
});
|
|
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
|
|
}
|
|
|
|
fn doc_json(fs: &FakeFs) -> serde_json::Value {
|
|
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
|
|
}
|
|
|
|
/// `agent_was_running` for the leaf `node` in the active persisted layout.
|
|
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|
let doc = doc_json(fs);
|
|
let tree: LayoutTree =
|
|
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
|
|
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
|
|
match node {
|
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
|
LayoutNode::Leaf(_) => None,
|
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
|
}
|
|
}
|
|
find(&tree.root, node)
|
|
}
|
|
|
|
fn make_use_case(store: &FakeStore, fs: &FakeFs) -> ReconcileLayouts {
|
|
ReconcileLayouts::new(
|
|
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
|
|
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 6. A persisted layout with a duplicate agent ⇒ `changed == true` and the
|
|
/// persisted version keeps a single "was running" leaf for the agent.
|
|
#[tokio::test]
|
|
async fn reconcile_persists_and_reports_changed_on_duplicate() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
let agent = aid(100);
|
|
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
let out = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.changed, true, "duplicate must be reconciled");
|
|
|
|
// Exactly one of the two leaves is still flagged running in the persisted doc.
|
|
let ra = was_running(&fs, a).expect("leaf a");
|
|
let rb = was_running(&fs, b).expect("leaf b");
|
|
assert_eq!(
|
|
[ra, rb].iter().filter(|x| **x).count(),
|
|
1,
|
|
"only one host stays running after reconciliation"
|
|
);
|
|
// First in pre-order is the host (both carried a signal).
|
|
assert_eq!(ra, true);
|
|
assert_eq!(rb, false);
|
|
}
|
|
|
|
/// 7. No-op: a second `execute` on an already-reconciled project ⇒
|
|
/// `changed == false` and **zero** writes occur.
|
|
#[tokio::test]
|
|
async fn reconcile_second_pass_is_noop_no_write() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
let agent = aid(100);
|
|
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
|
|
// First pass reconciles + writes once.
|
|
let first = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.changed, true);
|
|
assert_eq!(fs.write_calls(), 1, "first pass persists once");
|
|
|
|
// Second pass must be a strict no-op.
|
|
let writes_before = fs.write_calls();
|
|
let second = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.changed, false, "already reconciled → no change");
|
|
assert_eq!(
|
|
fs.write_calls(),
|
|
writes_before,
|
|
"no-op must not write anything"
|
|
);
|
|
}
|
|
|
|
/// 8. A layout WITHOUT duplicates ⇒ `changed == false`, unchanged, no write.
|
|
#[tokio::test]
|
|
async fn reconcile_no_duplicate_is_noop() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
// Distinct agents — no duplicate.
|
|
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
|
id: nid(1),
|
|
direction: Direction::Row,
|
|
children: vec![
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(a, Some(aid(100)), Some("c-a"), true)),
|
|
weight: 1.0,
|
|
},
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(b, Some(aid(101)), None, false)),
|
|
weight: 1.0,
|
|
},
|
|
],
|
|
}));
|
|
seed_layouts(&fs, lid(1), &tree);
|
|
let before = doc_json(&fs);
|
|
let writes_before = fs.write_calls();
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
let out = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.changed, false, "no duplicate → no change");
|
|
assert_eq!(fs.write_calls(), writes_before, "no write on no-op");
|
|
assert_eq!(doc_json(&fs), before, "persisted doc unchanged");
|
|
assert_eq!(was_running(&fs, a), Some(true));
|
|
}
|