memoryd: the edit journal, undo of the latest edit, and review
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
//! The edit journal, `log`, `undo`, and review (M5a spec, section 5). Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use memoryd::edit::{Context, apply};
|
||||
use memoryd::files::Store;
|
||||
use memoryd::{journal, review};
|
||||
use proto::{DataClass, MemoryArgs, MemoryFile, Timestamp};
|
||||
use tmp::TempDir;
|
||||
|
||||
fn now() -> Timestamp {
|
||||
Timestamp::parse("2026-09-24T20:14:37.123Z").unwrap()
|
||||
}
|
||||
|
||||
fn setup(tag: &str) -> (TempDir, Store) {
|
||||
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();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn ctx(untrusted: bool) -> Context {
|
||||
Context {
|
||||
session: "mm-s1".to_string(),
|
||||
call: 7,
|
||||
taint: DataClass::Private,
|
||||
untrusted,
|
||||
now: now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply and journal, as `memoryd` does for every write.
|
||||
fn write(store: &Store, json: &str, untrusted: bool) -> String {
|
||||
let args: MemoryArgs = serde_json::from_str(json).unwrap();
|
||||
let c = ctx(untrusted);
|
||||
let outcome = apply(store, &args, &c).unwrap();
|
||||
journal::record(store, &c, &outcome).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_write_is_journalled_with_a_daily_id() {
|
||||
let (dir, store) = setup("journal-ids");
|
||||
assert_eq!(
|
||||
write(
|
||||
&store,
|
||||
r#"{"action":"add","file":"memory","text":"a"}"#,
|
||||
false
|
||||
),
|
||||
"2026-09-24-0001"
|
||||
);
|
||||
assert_eq!(
|
||||
write(&store, r#"{"action":"note","text":"seen"}"#, true),
|
||||
"2026-09-24-0002"
|
||||
);
|
||||
let records = journal::records(&store).unwrap();
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(
|
||||
(
|
||||
records[0].action.as_str(),
|
||||
records[0].to.as_str(),
|
||||
records[0].before.clone(),
|
||||
records[0].after.clone()
|
||||
),
|
||||
("add", "memory.md", vec![], vec!["a".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
(records[1].to.as_str(), records[1].untrusted),
|
||||
("notes.md", true)
|
||||
);
|
||||
assert!(records[1].notes[0].starts_with("§ noted 2026-09-24T20:14Z in session mm-s1"));
|
||||
assert!(dir.path().join("memory/edits/2026-09.jsonl").exists());
|
||||
assert_eq!(
|
||||
journal::log(&store, 10).unwrap(),
|
||||
"2026-09-24-0001 2026-09-24T20:14:37.123Z mm-s1 add memory.md\n2026-09-24-0002 2026-09-24T20:14:37.123Z mm-s1 note notes.md untrusted"
|
||||
);
|
||||
assert_eq!(journal::log(&store, 1).unwrap().lines().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_the_latest_edit_of_a_file_only() {
|
||||
let (_dir, store) = setup("journal-undo");
|
||||
let first = write(
|
||||
&store,
|
||||
r#"{"action":"add","file":"memory","text":"a"}"#,
|
||||
false,
|
||||
);
|
||||
let second = write(
|
||||
&store,
|
||||
r#"{"action":"add","file":"memory","text":"b"}"#,
|
||||
false,
|
||||
);
|
||||
let note = write(&store, r#"{"action":"note","text":"n"}"#, false);
|
||||
assert_eq!(
|
||||
journal::undo(&store, &first, now()),
|
||||
Err(format!("{first} is not the latest edit of memory.md"))
|
||||
);
|
||||
assert_eq!(
|
||||
journal::undo(&store, ¬e, now()),
|
||||
Err(format!(
|
||||
"{note} changed no identity file; notes are append-only"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
journal::undo(&store, "2026-01-01-0001", now()),
|
||||
Err("no edit 2026-01-01-0001".to_string())
|
||||
);
|
||||
let done = journal::undo(&store, &second, now()).unwrap();
|
||||
assert!(
|
||||
done.starts_with(&format!("undone: {second} (memory.md restored")),
|
||||
"{done}"
|
||||
);
|
||||
assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]);
|
||||
let last = journal::records(&store).unwrap().pop().unwrap();
|
||||
assert_eq!(
|
||||
(last.action.as_str(), last.undoes.as_deref()),
|
||||
("undo", Some(second.as_str()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_refuses_when_the_file_changed_since() {
|
||||
let (dir, store) = setup("journal-changed");
|
||||
let id = write(
|
||||
&store,
|
||||
r#"{"action":"add","file":"user","text":"a"}"#,
|
||||
false,
|
||||
);
|
||||
std::fs::write(dir.path().join("memory/user.md"), "edited by hand\n").unwrap();
|
||||
assert_eq!(
|
||||
journal::undo(&store, &id, now()),
|
||||
Err(format!(
|
||||
"user.md has changed since {id}; nothing was undone"
|
||||
))
|
||||
);
|
||||
assert_eq!(store.entries(MemoryFile::User).unwrap(), ["edited by hand"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_stages_accepts_and_rejects() {
|
||||
let (_dir, store) = setup("review");
|
||||
let json = r#"{"action":"add","file":"memory","text":"a"}"#;
|
||||
let args: MemoryArgs = serde_json::from_str(json).unwrap();
|
||||
let answer = review::submit(&store, &args, json, &ctx(false)).unwrap();
|
||||
assert_eq!(
|
||||
answer,
|
||||
"waiting for the owner's review (p-2026-09-24-0001); nothing is changed until it is accepted"
|
||||
);
|
||||
let other = r#"{"action":"add","file":"memory","text":"b"}"#;
|
||||
let args: MemoryArgs = serde_json::from_str(other).unwrap();
|
||||
review::submit(&store, &args, other, &ctx(false)).unwrap();
|
||||
assert_eq!(
|
||||
store.entries(MemoryFile::Memory).unwrap(),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
let listed = review::list(&store).unwrap();
|
||||
assert_eq!(listed.lines().count(), 2);
|
||||
assert!(
|
||||
listed.starts_with("p-2026-09-24-0001 mm-s1 {"),
|
||||
"{listed}"
|
||||
);
|
||||
let accepted = review::accept(&store, "p-2026-09-24-0001", now()).unwrap();
|
||||
assert!(
|
||||
accepted.starts_with("accepted: p-2026-09-24-0001 as 2026-09-24-0001: memory.md updated"),
|
||||
"{accepted}"
|
||||
);
|
||||
assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]);
|
||||
assert_eq!(
|
||||
review::reject(&store, "p-2026-09-24-0002", now()),
|
||||
Ok("rejected: p-2026-09-24-0002".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
review::list(&store).unwrap(),
|
||||
"nothing is waiting for review"
|
||||
);
|
||||
assert_eq!(
|
||||
review::accept(&store, "p-2026-09-24-0002", now()),
|
||||
Err("nothing waiting as p-2026-09-24-0002".to_string())
|
||||
);
|
||||
let actions: Vec<String> = journal::records(&store)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|r| r.action)
|
||||
.collect();
|
||||
assert_eq!(actions, ["add", "reject"]);
|
||||
assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]);
|
||||
}
|
||||
Reference in New Issue
Block a user