//! The owner's undo, mirrored by what the ledger has decided (M5c spec, section 4; review of //! M5c-1, finding 2): an entry an undo brings back is concluded again only when its conclusion //! was made and then deleted; one never concluded (a secret session's) or still concluded (moved //! out by `consolidate`) gets nothing. #[path = "support/tmp.rs"] mod tmp; use std::sync::{Arc, Mutex}; use bxcore::{DataClass, MemoryArgs, Timestamp}; use memoryd::edit::{Context, apply}; use memoryd::files::Store; use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace}; use memoryd::honcho::mirror::Mirror; use memoryd::journal; use tmp::TempDir; const AGENT: &str = "boxmaker0"; fn now() -> Timestamp { Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap() } struct Fixture { store: Store, ledger: Arc>, mirror: Mirror, sent: usize, _dir: TempDir, } impl Fixture { fn new(tag: &str) -> Fixture { let dir = TempDir::new(tag); let starter = dir.write("etc/soul.md", "Soul.\n"); let store = Store::new(&dir.path().join("memory")); store.init(&starter).unwrap(); let mut ledger = Ledger::open(&dir.path().join("memory/honcho.jsonl")).unwrap(); ledger.append(Record::Began { time: now() }).unwrap(); let ledger = Arc::new(Mutex::new(ledger)); let mirror = Mirror::new(Arc::clone(&ledger), AGENT, "kyle"); Fixture { store, ledger, mirror, sent: 0, _dir: dir, } } /// A memory call from `session` at `taint`, applied, journalled and mirrored; its edit id. fn write(&self, session: &str, taint: DataClass, json: &str) -> String { let args: MemoryArgs = serde_json::from_str(json).unwrap(); let ctx = Context { session: session.to_string(), call: 1, taint, untrusted: false, now: now(), }; let outcome = apply(&self.store, &args, &ctx).unwrap(); let id = journal::record(&self.store, &ctx, &outcome).unwrap(); self.mirror .journalled(&self.store, Some(&id), now(), &|_| {}); id } fn undo(&self, id: &str) { journal::undo(&self.store, id, now()).unwrap(); self.mirror.journalled(&self.store, None, now(), &|_| {}); } /// The worker's part, done by hand: every waiting item marked sent, a conclusion with an id /// of its own. fn send_all(&mut self) { let mut ledger = self.ledger.lock().unwrap(); for (item, _) in ledger.outbox() { self.sent += 1; let ids = vec![format!("c{}", self.sent)]; ledger .append(Record::Sent { time: now(), item, ids, }) .unwrap(); } } /// The edit parts decided, in order, with their actions. fn decisions(&self) -> Vec<(Item, Action)> { self.ledger .lock() .unwrap() .decisions() .map(|(i, a)| (i.clone(), a.clone())) .collect() } fn concludes_of(&self, text: &str) -> usize { self.decisions() .iter() .filter(|(_, a)| matches!(a, Action::Conclude { text: t, .. } if t == text)) .count() } fn actions_of(&self, id: &str) -> Vec { self.decisions() .into_iter() .filter(|(i, _)| matches!(i, Item::Edit { id: e, .. } if e == id)) .map(|(_, a)| a) .collect() } } fn add(text: &str) -> String { format!(r#"{{"action":"add","file":"memory","text":"{text}"}}"#) } fn remove(text: &str) -> String { format!(r#"{{"action":"remove","file":"memory","old":"{text}"}}"#) } fn last_edit(store: &Store) -> String { journal::records(store).unwrap().pop().unwrap().id } #[test] fn undoing_a_consolidate_does_not_conclude_a_moved_entry_twice() { let mut f = Fixture::new("undo-consolidate"); f.write("mm-a", DataClass::Private, &add("Alpha.")); f.write("mm-a", DataClass::Private, &add("Bravo.")); f.send_all(); let consolidate = f.write( "mm-a", DataClass::Private, r#"{"action":"consolidate","file":"memory","entries":[{"old":"Alpha.","keep":true},{"old":"Bravo.","move":true}]}"#, ); assert!(f.actions_of(&consolidate).is_empty(), "a move asks nothing"); f.undo(&consolidate); assert!( f.actions_of(&last_edit(&f.store)).is_empty(), "Bravo.'s conclusion is still there" ); { let ledger = f.ledger.lock().unwrap(); assert!(ledger.concluded(Workspace::Trusted, AGENT, "Bravo.")); } f.write("mm-a", DataClass::Private, &remove("Bravo.")); f.send_all(); assert_eq!(f.concludes_of("Bravo."), 1, "one conclusion was ever made"); let ledger = f.ledger.lock().unwrap(); assert!(!ledger.concluded(Workspace::Trusted, AGENT, "Bravo.")); assert_eq!( ledger.conclusion(Workspace::Trusted, AGENT, "Bravo."), None, "and it is deleted" ); assert!( ledger .conclusion(Workspace::Trusted, AGENT, "Alpha.") .is_some() ); } #[test] fn undoing_a_remove_concludes_the_entry_again() { let mut f = Fixture::new("undo-remove"); f.write("mm-a", DataClass::Private, &add("Charlie.")); f.send_all(); let removed = f.write("mm-a", DataClass::Private, &remove("Charlie.")); f.send_all(); f.undo(&removed); assert!(matches!( &f.actions_of(&last_edit(&f.store))[..], [Action::Conclude { text, .. }] if text == "Charlie." )); f.send_all(); let ledger = f.ledger.lock().unwrap(); assert!(ledger.concluded(Workspace::Trusted, AGENT, "Charlie.")); assert_eq!( ledger .conclusion(Workspace::Trusted, AGENT, "Charlie.") .map(|(_, id)| id), Some("c3".to_string()) ); } #[test] fn undoing_an_edit_of_secret_entries_concludes_nothing() { let mut f = Fixture::new("undo-secret"); f.write("mm-s", DataClass::Secret, &add("Delta.")); f.send_all(); let removed = f.write("mm-a", DataClass::Private, &remove("Delta.")); f.send_all(); f.undo(&removed); assert!( f.actions_of(&last_edit(&f.store)).is_empty(), "{:?}", f.decisions() ); assert_eq!(f.concludes_of("Delta."), 0); // A secret replace of a clean entry, undone: the clean entry comes back, the secret one goes. f.write("mm-a", DataClass::Private, &add("Echo.")); f.send_all(); let replaced = f.write( "mm-s", DataClass::Secret, r#"{"action":"replace","file":"memory","old":"Echo.","text":"Echo, secretly."}"#, ); f.send_all(); f.undo(&replaced); let undo = f.actions_of(&last_edit(&f.store)); assert_eq!(undo.len(), 2, "{undo:?}"); assert!(matches!(&undo[0], Action::Unconclude { text, .. } if text == "Echo, secretly.")); assert!(matches!(&undo[1], Action::Conclude { text, .. } if text == "Echo.")); assert_eq!(f.concludes_of("Echo, secretly."), 0); }