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>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
//! What the ledger has decided about one conclusion (M5c spec, section 4): whether it is made, or
|
||||||
|
//! was made and then deleted, by the `Conclude` and `Unconclude` decisions alone, sent or not.
|
||||||
|
//! The owner's undo asks this, so that it concludes again only what it had deleted.
|
||||||
|
|
||||||
|
use crate::honcho::ledger::{Action, Ledger, Workspace};
|
||||||
|
|
||||||
|
impl Ledger {
|
||||||
|
/// Whether the latest decided `Conclude` or `Unconclude` of `text` about `peer` in
|
||||||
|
/// `workspace`, in the ledger's order, is a `Conclude`: its conclusion is made, or will be.
|
||||||
|
pub fn concluded(&self, workspace: Workspace, peer: &str, text: &str) -> bool {
|
||||||
|
self.last_of(workspace, peer, text) == Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a `Conclude` of the same was decided, and the latest was followed by an
|
||||||
|
/// `Unconclude`: its conclusion was made, then deleted.
|
||||||
|
pub fn unconcluded(&self, workspace: Workspace, peer: &str, text: &str) -> bool {
|
||||||
|
self.last_of(workspace, peer, text) == Some(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest of `Conclude` (true) and, after a `Conclude`, `Unconclude` (false) for the key;
|
||||||
|
/// `None` when no `Conclude` was ever decided.
|
||||||
|
fn last_of(&self, workspace: Workspace, peer: &str, text: &str) -> Option<bool> {
|
||||||
|
let key = |w: &Workspace, p: &str, t: &str| *w == workspace && p == peer && t == text;
|
||||||
|
self.decisions()
|
||||||
|
.fold(None, |last, (_, action)| match action {
|
||||||
|
Action::Conclude {
|
||||||
|
workspace: w,
|
||||||
|
peer: p,
|
||||||
|
text: t,
|
||||||
|
..
|
||||||
|
} if key(w, p, t) => Some(true),
|
||||||
|
Action::Unconclude {
|
||||||
|
workspace: w,
|
||||||
|
peer: p,
|
||||||
|
text: t,
|
||||||
|
} if key(w, p, t) => last.map(|_| false),
|
||||||
|
_ => last,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,22 @@ pub fn actions(
|
|||||||
kept
|
kept
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an undo's `action` is kept: an entry it brings back is concluded again only when its
|
||||||
|
/// conclusion was made and then deleted (M5c spec, section 4: "any it had deleted, written
|
||||||
|
/// again"). One never concluded (a secret session's, from before sync, skipped) or still
|
||||||
|
/// concluded (moved out by `consolidate`) gets nothing. Every other action is kept.
|
||||||
|
fn brought_back(ledger: &Ledger, action: &Action) -> bool {
|
||||||
|
match action {
|
||||||
|
Action::Conclude {
|
||||||
|
workspace,
|
||||||
|
peer,
|
||||||
|
text,
|
||||||
|
..
|
||||||
|
} => ledger.unconcluded(*workspace, peer, text),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// With `[honcho]`: decides each journalled edit into the ledger.
|
/// With `[honcho]`: decides each journalled edit into the ledger.
|
||||||
pub struct Mirror {
|
pub struct Mirror {
|
||||||
ledger: Arc<Mutex<Ledger>>,
|
ledger: Arc<Mutex<Ledger>>,
|
||||||
@@ -207,8 +223,9 @@ impl Mirror {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decide the journalled edit `id` (the latest when `None`), called under the index lock
|
/// Decide the journalled edit `id` (the latest when `None`), called under the index lock
|
||||||
/// right after it was journalled, so the ledger has the journal's order. Its parts are
|
/// right after it was journalled, so the ledger has the journal's order; the ledger's lock is
|
||||||
/// `Edit { id, part }` from 0. An edit already decided, or asking nothing, adds no line. A
|
/// taken inside the index lock, and before an undo is decided against it. Its parts are
|
||||||
|
/// `Edit { id, part }` from 0, numbering what is decided. An edit already decided, or asking nothing, adds no line. A
|
||||||
/// failure is logged; the edit itself stands.
|
/// failure is logged; the edit itself stands.
|
||||||
pub fn journalled(&self, store: &Store, id: Option<&str>, now: Timestamp, log: &dyn Fn(&str)) {
|
pub fn journalled(&self, store: &Store, id: Option<&str>, now: Timestamp, log: &dyn Fn(&str)) {
|
||||||
let records = match journal::records(store) {
|
let records = match journal::records(store) {
|
||||||
@@ -229,8 +246,11 @@ impl Mirror {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let session = session_of(record, &records);
|
let session = session_of(record, &records);
|
||||||
let actions = actions(record, session.as_ref(), &self.agent, &self.owner);
|
|
||||||
let mut book = self.ledger.lock().unwrap_or_else(|p| p.into_inner());
|
let mut book = self.ledger.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let mut actions = actions(record, session.as_ref(), &self.agent, &self.owner);
|
||||||
|
if record.undoes.is_some() {
|
||||||
|
actions.retain(|a| brought_back(&book, a));
|
||||||
|
}
|
||||||
for (part, action) in actions.into_iter().enumerate() {
|
for (part, action) in actions.into_iter().enumerate() {
|
||||||
let Ok(part) = u32::try_from(part) else {
|
let Ok(part) = u32::try_from(part) else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod concluded;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod decide;
|
pub mod decide;
|
||||||
pub mod edits;
|
pub mod edits;
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user