//! T5 tests for [`SnapshotRunningAgents`]. //! //! At close time, before the global PTY kill, the use case must freeze on each //! agent-bearing leaf whether that agent's PTY was still live //! (`agent_was_running`). Liveness is derived purely from the live-session //! registry (a [`LiveAgentRegistry`]), never from CLI parsing. //! //! Every port is faked in-memory. We assert the persisted `layouts.json` flags //! and the ordering guarantee (snapshot reads liveness BEFORE the kill). use std::collections::{HashMap, HashSet}; 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::{LiveAgentRegistry, SnapshotRunningAgents, SnapshotRunningAgentsInput}; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, dirs: HashSet, } #[derive(Default, Clone)] struct FakeFs(Arc>); impl FakeFs { fn read_file(&self, path: &str) -> Option> { self.0.lock().unwrap().files.get(path).cloned() } fn put(&self, path: &str, data: &[u8]) { self.0 .lock() .unwrap() .files .insert(path.to_owned(), data.to_vec()); } fn writes(&self) -> usize { self.0.lock().unwrap().files.len() } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.0 .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.0 .lock() .unwrap() .files .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let inner = self.0.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.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } #[derive(Default)] struct FakeStoreInner { projects: Vec, } #[derive(Default, Clone)] struct FakeStore(Arc>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().projects.clone()) } async fn load_project(&self, id: ProjectId) -> Result { 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 { Ok(Workspace::default()) } } /// A controllable liveness registry. Liveness is keyed on the hosting **node** /// (the leaf), matching the snapshot's per-cell query: a leaf is "live" iff its /// node id is in the set. An optional agent set backs the legacy /// `is_agent_live` query (unused by the snapshot now, kept for the trait). #[derive(Default, Clone)] struct FakeLive { nodes: Arc>>, agents: Arc>>, } impl FakeLive { /// Seeds the set of live **nodes** (the cells whose PTY is alive). fn with_nodes(nodes: &[NodeId]) -> Self { Self { nodes: Arc::new(Mutex::new(nodes.iter().copied().collect())), agents: Arc::new(Mutex::new(HashSet::new())), } } /// Simulates a PTY kill: forget every live node/agent. fn kill_all(&self) { self.nodes.lock().unwrap().clear(); self.agents.lock().unwrap().clear(); } } impl LiveAgentRegistry for FakeLive { fn is_agent_live(&self, agent_id: &AgentId) -> bool { self.agents.lock().unwrap().contains(agent_id) } fn is_node_live(&self, node_id: &NodeId) -> bool { self.nodes.lock().unwrap().contains(node_id) } } // --------------------------------------------------------------------------- // 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) -> LeafCell { LeafCell { id: node, session: None, agent, conversation_id: None, engine_session_id: None, agent_was_running: false, } } 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(); } /// Seeds a valid `layouts.json` with one active layout holding `tree`. 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() } /// Walks the active layout tree and returns `agent_was_running` for the leaf /// `node`, or `None` if absent. fn was_running(fs: &FakeFs, node: NodeId) -> Option { 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 { 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, live: &FakeLive) -> SnapshotRunningAgents { SnapshotRunningAgents::new( Arc::new(store.clone()) as Arc, Arc::new(fs.clone()) as Arc, Arc::new(live.clone()) as Arc, ) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// A live agent's leaf is persisted with `agent_was_running = true`. #[tokio::test] async fn live_agent_is_marked_running() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let leaf = nid(10); let agent = aid(100); seed_layouts( &fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))), ); let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); let out = uc .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.running, 1); assert_eq!(out.stopped, 0); assert_eq!(was_running(&fs, leaf), Some(true)); } /// An agent whose PTY has already exited is persisted with `false`. #[tokio::test] async fn stopped_agent_is_marked_not_running() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let leaf = nid(10); let agent = aid(100); seed_layouts( &fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))), ); // Registry is empty: the agent has no live session. let live = FakeLive::default(); let uc = make_use_case(&store, &fs, &live); let out = uc .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.running, 0); assert_eq!(out.stopped, 1); assert_eq!(was_running(&fs, leaf), Some(false)); } /// Mixed tree (split with one live agent, one stopped agent, one plain leaf): /// each agent leaf gets the right flag; the non-agent leaf is untouched. #[tokio::test] async fn mixed_tree_flags_each_agent_independently() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let live_leaf = nid(10); let dead_leaf = nid(11); let plain_leaf = nid(12); let live_agent = aid(100); let dead_agent = aid(101); let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: nid(1), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(agent_leaf(live_leaf, Some(live_agent))), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(agent_leaf(dead_leaf, Some(dead_agent))), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(agent_leaf(plain_leaf, None)), weight: 1.0, }, ], })); seed_layouts(&fs, lid(1), &tree); let live = FakeLive::with_nodes(&[live_leaf]); let uc = make_use_case(&store, &fs, &live); let out = uc .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.running, 1); assert_eq!(out.stopped, 1); assert_eq!(was_running(&fs, live_leaf), Some(true)); assert_eq!(was_running(&fs, dead_leaf), Some(false)); assert_eq!(was_running(&fs, plain_leaf), Some(false)); // default, untouched } /// Ordering guarantee: the snapshot reads liveness BEFORE the kill. Running the /// snapshot on the live registry persists `true`; running it AFTER `kill_all` /// (simulating a kill-then-snapshot mistake) would persist `false`. #[tokio::test] async fn snapshot_reads_liveness_before_kill() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let leaf = nid(10); let agent = aid(100); seed_layouts( &fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))), ); let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); // Correct order (composition root contract): snapshot THEN kill. uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); live.kill_all(); assert_eq!( was_running(&fs, leaf), Some(true), "snapshot taken before kill must record running" ); // Re-seed and demonstrate the opposite order yields `false` — proving the // flag is sensitive to registry state at call time (hence order matters). seed_layouts( &fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))), ); uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!( was_running(&fs, leaf), Some(false), "snapshot taken after kill records not-running" ); } /// A layout with no agent leaf is a no-op: nothing is written. #[tokio::test] async fn no_agent_leaf_is_noop() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let leaf = nid(10); seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, None))); let writes_before = fs.writes(); let live = FakeLive::default(); let uc = make_use_case(&store, &fs, &live); let out = uc .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.running, 0); assert_eq!(out.stopped, 0); // No agent leaf → no persistence triggered (file count unchanged). assert_eq!(fs.writes(), writes_before); assert_eq!(was_running(&fs, leaf), Some(false)); } /// Duplicate leaves for the **same** agent (the singleton-invariant case): only /// the cell whose PTY is actually live is flagged running. The other leaf — /// pinning the same agent but never launched (or refused by the guard) — stays /// `false`. This is exactly why liveness is keyed on the node, not the agent: /// an agent-keyed query would wrongly mark BOTH leaves as running. #[tokio::test] async fn duplicate_leaves_same_agent_only_live_node_is_running() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let leaf_a = nid(10); let leaf_b = nid(11); let agent = aid(100); // SAME agent on both leaves let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: nid(1), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(agent_leaf(leaf_a, Some(agent))), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(agent_leaf(leaf_b, Some(agent))), weight: 1.0, }, ], })); seed_layouts(&fs, lid(1), &tree); // Only node A hosts a live session. let live = FakeLive::with_nodes(&[leaf_a]); let uc = make_use_case(&store, &fs, &live); let out = uc .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.running, 1, "only the live cell counts as running"); assert_eq!( out.stopped, 1, "the duplicate (dead) cell counts as stopped" ); assert_eq!(was_running(&fs, leaf_a), Some(true)); assert_eq!( was_running(&fs, leaf_b), Some(false), "the duplicate leaf for the same agent must NOT be marked running" ); }