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>
220 lines
7.0 KiB
Rust
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);
|
|
}
|