Files
boxmaker/crates/memoryd/tests/honcho_mirror_undo.rs
T
kyleandClaude Opus 5.5 7c207f6ec6 review: an undo concludes again only what it had deleted
Undoing a consolidate concluded its moved entries a second time, and a later
removal deleted only the newer conclusion. An undo's added entry is now
concluded only when the ledger decided a Conclude for it and then an
Unconclude: an entry still concluded, or never concluded (a secret session's,
one from before sync), gets nothing. Ledger::concluded and unconcluded answer
from the decisions, in a module of their own to keep ledger.rs under 500 lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:06:28 -07:00

220 lines
7.0 KiB
Rust

//! 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<Mutex<Ledger>>,
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<Action> {
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);
}