memoryd: sync begins when Honcho is turned on, and conclusions are matched by peer

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 13:40:48 -07:00
co-authored by ornith-1.5-35b-a3b
parent fb2afa1979
commit 5f4e0d9997
25 changed files with 720 additions and 80 deletions
+11 -3
View File
@@ -3,12 +3,13 @@
//! and the session's log are given, and nothing is read or written here.
use bxcore::sanitize::refuse;
use bxcore::{DataClass, LogRecord, SessionStateReply};
use bxcore::{DataClass, LogRecord, SessionStateReply, Timestamp};
use crate::honcho::config::is_name;
use crate::honcho::ledger::{Action, Workspace};
/// Why a turn goes nowhere: the `why` of its `Action::Skip`.
pub const BEFORE_SYNC: &str = "the turn is from before sync began";
pub const SECRET_CLASS: &str = "the session is secret";
pub const NO_SPEAKER: &str = "the turn has no single speaker";
pub const BAD_SPEAKER: &str = "the speaker is not a Honcho peer name";
@@ -135,8 +136,9 @@ fn labelled_untrusted(records: &[LogRecord]) -> bool {
}
/// Where turn `turn` of the session whose log is `records` goes. `state` is `brokerd`'s answer
/// for the session; `agent` is `[honcho] agent`. The checks run in this order, and the first that
/// holds decides: the class `secret`; no speaker; a speaker that is not a peer name, or is the
/// for the session; `agent` is `[honcho] agent`; `began` is when sync began (the ledger's
/// `Began`). The checks run in this order, and the first that holds decides: the turn is from
/// before `began`; the class `secret`; no speaker; a speaker that is not a peer name, or is the
/// agent's; no reply; a secret in the user's text or the reply. Otherwise the turn goes to the
/// untrusted workspace if `brokerd` or any tool result up to the turn's end says untrusted, else
/// to the trusted one.
@@ -145,6 +147,7 @@ pub fn decide(
records: &[LogRecord],
turn: u64,
agent: &str,
began: Option<Timestamp>,
) -> Result<Action, NoSuchTurn> {
let missing = NoSuchTurn {
turn,
@@ -159,6 +162,11 @@ pub fn decide(
})
};
if let (Some(began), Some(LogRecord::User { time, .. })) = (began, this.first())
&& *time < began
{
return skip(BEFORE_SYNC);
}
if state.class == DataClass::Secret {
return skip(SECRET_CLASS);
}
+53 -7
View File
@@ -50,7 +50,11 @@ pub enum Action {
text: String,
},
/// Delete the conclusion that a sent `Conclude` with this text created.
Unconclude { workspace: Workspace, text: String },
Unconclude {
workspace: Workspace,
peer: String,
text: String,
},
/// A message from the agent's peer in the fleet workspace.
Fleet { session: SessionId, text: String },
/// `retracted: <text>`, from the same peer in the same workspace.
@@ -63,6 +67,9 @@ pub enum Action {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Record {
/// When sync began: always the ledger's first line, written once, by `memoryd` at its first
/// start with `[honcho]`. A turn from before it is not synced.
Began { time: Timestamp },
Decided {
time: Timestamp,
item: Item,
@@ -115,6 +122,10 @@ pub enum LedgerError {
Encode(serde_json::Error),
AlreadyDecided(Item),
NotWaiting(Item),
/// A `Began` after other records, or a second one.
BeganNotFirst,
/// A ledger whose first line is not its `Began`.
NotBegun,
}
impl std::fmt::Display for LedgerError {
@@ -129,6 +140,8 @@ impl std::fmt::Display for LedgerError {
LedgerError::NotWaiting(item) => {
write!(f, "{}: not waiting to be sent", label(item))
}
LedgerError::BeganNotFirst => write!(f, "sync began, but not on the first line"),
LedgerError::NotBegun => write!(f, "the first line is not when sync began"),
}
}
}
@@ -165,6 +178,10 @@ pub struct Ledger {
sends: Vec<Item>,
/// Every failed item, in the order of its `Failed` record.
fails: Vec<Item>,
/// When sync began: the `Began` record's time.
began: Option<Timestamp>,
/// How many records the ledger holds.
records: usize,
repaired: bool,
}
@@ -179,6 +196,8 @@ impl Ledger {
entries: HashMap::new(),
sends: Vec::new(),
fails: Vec::new(),
began: None,
records: 0,
repaired: false,
};
let Some(bytes) = read(path)? else {
@@ -188,7 +207,13 @@ impl Ledger {
let lines = bytes.get(..whole).unwrap_or_default();
for (index, line) in lines.split_inclusive(|b| *b == b'\n').enumerate() {
let record = parse(path, index + 1, line)?;
if let Err(e) = ledger.check(&record) {
let first_not_began = index == 0 && !matches!(record, Record::Began { .. });
let checked = if first_not_began {
Err(LedgerError::NotBegun)
} else {
ledger.check(&record)
};
if let Err(e) = checked {
return Err(LedgerError::Damaged {
path: path.to_path_buf(),
line: index + 1,
@@ -219,6 +244,11 @@ impl Ledger {
Ok(())
}
/// When sync began, once the ledger has its `Began` record.
pub fn began(&self) -> Option<Timestamp> {
self.began
}
/// Whether `item` has its `Decided` record.
pub fn is_decided(&self, item: &Item) -> bool {
self.entries.contains_key(item)
@@ -250,9 +280,14 @@ impl Ledger {
}
}
/// The latest sent `Conclude` of `text` in `workspace` that no sent `Unconclude` has undone:
/// its item and the conclusion's id.
pub fn conclusion(&self, workspace: Workspace, text: &str) -> Option<(Item, String)> {
/// The latest sent `Conclude` of `text` about `peer` in `workspace` that no sent `Unconclude`
/// of the same has undone: its item and the conclusion's id.
pub fn conclusion(
&self,
workspace: Workspace,
peer: &str,
text: &str,
) -> Option<(Item, String)> {
let mut live: Vec<(Item, String)> = Vec::new();
for item in &self.sends {
let Some(entry) = self.entries.get(item) else {
@@ -264,17 +299,19 @@ impl Ledger {
match &entry.action {
Action::Conclude {
workspace: w,
peer: p,
text: t,
..
} if *w == workspace && t == text => {
} if *w == workspace && p == peer && t == text => {
if let Some(id) = ids.first() {
live.push((item.clone(), id.clone()));
}
}
Action::Unconclude {
workspace: w,
peer: p,
text: t,
} if *w == workspace && t == text => {
} if *w == workspace && p == peer && t == text => {
live.pop();
}
_ => {}
@@ -319,6 +356,13 @@ impl Ledger {
/// is waiting.
fn check(&self, record: &Record) -> Result<(), LedgerError> {
match record {
Record::Began { .. } => {
if self.records > 0 {
Err(LedgerError::BeganNotFirst)
} else {
Ok(())
}
}
Record::Decided { item, .. } => {
if self.entries.contains_key(item) {
Err(LedgerError::AlreadyDecided(item.clone()))
@@ -336,7 +380,9 @@ impl Ledger {
/// Take a checked record into the state.
fn apply(&mut self, record: Record) {
self.records += 1;
match record {
Record::Began { time } => self.began = Some(time),
Record::Decided { item, action, .. } => {
let state = match action {
Action::Skip { .. } => State::Skipped,
+1
View File
@@ -96,6 +96,7 @@ fn identity_actions(
.into_iter()
.map(|text| Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text,
})
.collect();
+16 -2
View File
@@ -3,12 +3,12 @@
use std::sync::{Arc, Mutex};
use bxcore::SessionId;
use bxcore::{SessionId, Timestamp};
use crate::config::Config;
use crate::honcho::client::{Clients, Timeouts};
use crate::honcho::config::HonchoConfig;
use crate::honcho::ledger::Ledger;
use crate::honcho::ledger::{Ledger, Record};
use crate::honcho::mirror::Mirror;
use crate::honcho::state;
use crate::honcho::turns::{LEDGER, TurnSync};
@@ -25,6 +25,20 @@ pub struct Opened {
}
impl Opened {
/// The first time: sync begins now. The `Began` line is written and synced once `memory/`
/// exists and before any socket is bound, so every turn reported later is after it.
pub fn begin(&self) -> Result<(), StartError> {
let sync = self.sync.lock().unwrap_or_else(|p| p.into_inner());
let mut ledger = sync.ledger();
if ledger.began().is_none() {
let began = Record::Began {
time: Timestamp::now(),
};
ledger.append(began).map_err(StartError::Ledger)?;
}
Ok(())
}
/// The memory writes' side of the ledger (task 12).
pub fn mirror(&self) -> Mirror {
let ledger = self
+2 -1
View File
@@ -218,7 +218,8 @@ impl TurnSync {
return Walk::Stop;
};
for turn in undecided {
let Ok(action) = decide(&state, &records, turn, &self.agent) else {
let began = self.ledger().began();
let Ok(action) = decide(&state, &records, turn, &self.agent, began) else {
continue;
};
let record = Record::Decided {
+9 -2
View File
@@ -345,10 +345,17 @@ impl Worker {
text,
},
),
(Item::Edit { .. }, Action::Unconclude { workspace, text }) => (
(
Item::Edit { .. },
Action::Unconclude {
workspace,
peer,
text,
},
) => (
workspace,
Job::Unconclude {
id: ledger.conclusion(workspace, &text).map(|(_, id)| id),
id: ledger.conclusion(workspace, &peer, &text).map(|(_, id)| id),
},
),
(Item::Edit { id, .. }, Action::Fleet { session, text }) => (
+5 -2
View File
@@ -93,8 +93,8 @@ enum Which {
}
/// With `[honcho]`, read the ledger through and the keys first. Then create what is missing
/// under `memory/`, open the index and catch it up, bind the three sockets, and start the Honcho
/// worker. The error names what failed.
/// under `memory/` (and, the first time, write when sync began), open the index and catch it up,
/// bind the three sockets, and start the Honcho worker. The error names what failed.
pub fn start(config: Config, log: Log) -> Result<Running, StartError> {
let opened = match config.honcho_config()? {
Some(honcho) => Some(worker::open(&config, &honcho, &log)?),
@@ -102,6 +102,9 @@ pub fn start(config: Config, log: Log) -> Result<Running, StartError> {
};
let store = Store::new(&config.memory_dir());
store.init(&config.memory.soul_starter)?;
if let Some(opened) = &opened {
opened.begin()?;
}
let index = Index::open(&config.index_path(), config.index.max_class)?;
let (_counts, problems) = index.catch_up(&config.sessions_dir(), &store.path("notes.md"))?;
for problem in problems {
+133
View File
@@ -0,0 +1,133 @@
//! When sync began, as `memoryd` serves (M5c spec, section 5): the `began` line written once, at
//! the first start, before any socket is bound; a second start leaves it as it is; and a turn
//! logged before it, reported after, is skipped. Every wait has a deadline. Do not edit.
#[path = "support/brokerd.rs"]
mod brokerd;
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
#[path = "support/turns.rs"]
mod turns;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::{Answer, FakeBrokerd};
use bxcore::{Envelope, Message, SessionId, SessionUpdated, Timestamp, read_frame, write_frame};
use logs::assistant;
use memoryd::config::Config;
use memoryd::honcho::decide::BEFORE_SYNC;
use memoryd::honcho::ledger::{Action, Ledger, Record};
use memoryd::honcho::start;
use memoryd::serve;
use tmp::TempDir;
use turns::{clean, spoken_at, write_turns};
const SECTION: &str = "[honcho]
url = \"http://127.0.0.1:9\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
";
fn config(dir: &TempDir) -> Config {
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{SECTION}",
dir.path().display(),
soul.display()
);
Config::parse(&text).unwrap()
}
fn ledger(dir: &TempDir) -> PathBuf {
dir.path().join("memory/honcho.jsonl")
}
fn records(dir: &TempDir) -> Vec<Record> {
std::fs::read_to_string(ledger(dir))
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn the_first_start_writes_when_sync_began_once() {
let dir = TempDir::new("began-start");
let config = config(&dir);
let before = Timestamp::now();
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
// `start` has returned: the sockets are bound, and the line is already on disk.
assert!(config.loop_socket().exists());
let began = match records(&dir).as_slice() {
[Record::Began { time }] => *time,
other => panic!("{other:?}"),
};
assert!(began >= before && began <= Timestamp::now());
drop(running);
// A start again (the ledger opened and begun as at start) leaves it as it is.
let honcho = config.honcho_config().unwrap().unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let opened = start::open(&config, &honcho, &log).unwrap();
opened.begin().unwrap();
assert_eq!(records(&dir), vec![Record::Began { time: began }]);
assert_eq!(Ledger::open(&ledger(&dir)).unwrap().began(), Some(began));
}
#[test]
fn a_turn_from_before_sync_began_is_skipped() {
let dir = TempDir::new("began-old");
let config = config(&dir);
let old = Timestamp::parse("2026-09-01T10:00:00.000Z").unwrap();
write_turns(
dir.path(),
"mm-a",
&[vec![
spoken_at(old, "hello", Some("kyle")),
assistant(2, Some("hi"), &[]),
]],
);
let _brokerd = FakeBrokerd::start(&config.state_socket(), Answer::State(clean()));
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
std::thread::spawn(move || running.run());
let mut stream = UnixStream::connect(config.loop_socket()).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let report = Envelope {
v: 1,
id: 1,
r#final: true,
msg: Message::SessionUpdated(SessionUpdated {
session: SessionId::new("mm-a").unwrap(),
turn: 1,
}),
};
write_frame(&mut stream, &report).unwrap();
assert!(matches!(
read_frame(&mut stream).unwrap().msg,
Message::Ok(_)
));
let deadline = Instant::now() + Duration::from_secs(10);
let skipped = Action::Skip {
why: BEFORE_SYNC.to_string(),
};
let decided = || {
records(&dir)
.into_iter()
.any(|r| matches!(&r, Record::Decided { action, .. } if *action == skipped))
};
while Instant::now() < deadline && !decided() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(decided(), "{:?}", records(&dir));
}
+8 -7
View File
@@ -1,6 +1,7 @@
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): the trusted or untrusted
//! workspace, or nowhere and why, from `brokerd`'s answer and the session's log; the order of the
//! checks; and the helpers that find a turn in the log. Do not edit.
//! checks; and the helpers that find a turn in the log. With no time sync began (`None`); that
//! check is in `honcho_decide_began.rs`. Do not edit.
use bxcore::sanitize::TAGS;
use bxcore::{
@@ -134,7 +135,7 @@ fn skip(why: &str) -> Action {
/// `decide` for the last turn of `records`.
fn last(state: &SessionStateReply, records: &[LogRecord]) -> Action {
decide(state, records, turns(records), AGENT).unwrap()
decide(state, records, turns(records), AGENT, None).unwrap()
}
/// `n` characters of `A1b2`…, which the secret rules take for a key's run.
@@ -192,14 +193,14 @@ fn a_turn_not_in_the_log_is_an_error() {
turn("two", Some("kyle"), "b"),
]);
for n in [0, 3, u64::MAX] {
let err = decide(&clean(), &records, n, AGENT).unwrap_err();
let err = decide(&clean(), &records, n, AGENT, None).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: n, turns: 2 });
}
assert_eq!(
NoSuchTurn { turn: 3, turns: 2 }.to_string(),
"turn 3 is not in the session's log, which has 2 turns"
);
let err = decide(&clean(), &[], 1, AGENT).unwrap_err();
let err = decide(&clean(), &[], 1, AGENT, None).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: 1, turns: 0 });
}
@@ -251,11 +252,11 @@ fn a_label_after_the_turn_does_not_count() {
turn_with_tool(Some("kyle"), true),
]);
assert_eq!(
decide(&clean(), &records, 1, AGENT).unwrap(),
decide(&clean(), &records, 1, AGENT, None).unwrap(),
to(Workspace::Trusted, "kyle")
);
assert_eq!(
decide(&clean(), &records, 2, AGENT).unwrap(),
decide(&clean(), &records, 2, AGENT, None).unwrap(),
to(Workspace::Untrusted, "kyle")
);
}
@@ -305,7 +306,7 @@ fn a_speaker_with_the_agents_name_goes_nowhere() {
assert_eq!(last(&clean(), &records), skip(AGENT_SPEAKER));
let records = log(&[turn("hello", Some("box.maker"), "hi")]);
assert_eq!(
decide(&clean(), &records, 1, "box_maker").unwrap(),
decide(&clean(), &records, 1, "box_maker", None).unwrap(),
skip(AGENT_SPEAKER)
);
let records = log(&[turn("hello", Some("Boxmaker0"), "hi")]);
+110
View File
@@ -0,0 +1,110 @@
//! A turn from before sync began goes nowhere (M5c spec, section 5): the first check, before
//! every other, by the time of the turn's `User` record against the ledger's `Began`. Do not
//! edit.
use bxcore::{DataClass, LogRecord, SessionStateReply, Timestamp};
use memoryd::honcho::decide::{BEFORE_SYNC, SECRET_CLASS, decide};
use memoryd::honcho::ledger::{Action, Workspace};
const AGENT: &str = "boxmaker0";
fn at(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
/// One finished turn, its `User` record at `time`.
fn turn(time: &str, speaker: Option<&str>) -> Vec<LogRecord> {
vec![
LogRecord::User {
time: at(time),
content: "hello".to_string(),
speaker: speaker.map(str::to_string),
},
LogRecord::Assistant {
time: at(time),
content: Some("hi".to_string()),
reasoning_content: None,
tool_calls: Vec::new(),
},
]
}
fn state(class: DataClass) -> SessionStateReply {
SessionStateReply {
class,
untrusted: false,
}
}
fn skip(why: &str) -> Action {
Action::Skip {
why: why.to_string(),
}
}
const BEGAN: &str = "2026-09-28T10:00:00.000Z";
#[test]
fn the_text() {
assert_eq!(BEFORE_SYNC, "the turn is from before sync began");
}
#[test]
fn a_turn_from_before_sync_began_goes_nowhere() {
let began = Some(at(BEGAN));
let clean = state(DataClass::Private);
let before = turn("2026-09-28T09:59:59.999Z", Some("kyle"));
assert_eq!(
decide(&clean, &before, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
let same = turn(BEGAN, Some("kyle"));
let to_trusted = Action::Turn {
workspace: Workspace::Trusted,
speaker: "kyle".to_string(),
};
assert_eq!(
decide(&clean, &same, 1, AGENT, began),
Ok(to_trusted.clone())
);
let after = turn("2026-09-28T10:00:00.001Z", Some("kyle"));
assert_eq!(
decide(&clean, &after, 1, AGENT, began),
Ok(to_trusted.clone())
);
// Without a time sync began, no turn is too old.
assert_eq!(decide(&clean, &before, 1, AGENT, None), Ok(to_trusted));
}
#[test]
fn it_is_the_first_check() {
let began = Some(at(BEGAN));
// A turn from before that also breaks the class and the speaker rules: before-sync wins.
let before = turn("2026-09-28T09:00:00.000Z", None);
assert_eq!(
decide(&state(DataClass::Secret), &before, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
// The same turn after it: the next rule decides.
let after = turn("2026-09-28T11:00:00.000Z", None);
assert_eq!(
decide(&state(DataClass::Secret), &after, 1, AGENT, began),
Ok(skip(SECRET_CLASS))
);
}
#[test]
fn each_turn_by_its_own_time() {
let began = Some(at(BEGAN));
let mut records = turn("2026-09-28T09:00:00.000Z", Some("kyle"));
records.extend(turn("2026-09-28T11:00:00.000Z", Some("kyle")));
let clean = state(DataClass::Private);
assert_eq!(
decide(&clean, &records, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
assert!(matches!(
decide(&clean, &records, 2, AGENT, began),
Ok(Action::Turn { .. })
));
}
+64 -3
View File
@@ -49,9 +49,15 @@ fn conclude(peer: &str, text: &str) -> Action {
}
}
/// The deletion of the owner's conclusion `text` (a `user.md` entry dropped).
fn unconclude(text: &str) -> Action {
unconclude_about("kyle", text)
}
fn unconclude_about(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
@@ -144,7 +150,8 @@ fn a_conclusion_goes_into_the_sessions_memory_session() {
);
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "Prefers UTC."),
sync.ledger()
.conclusion(Workspace::Trusted, "kyle", "Prefers UTC."),
Some((edit("e1", 0), CONCLUSION.to_string()))
);
}
@@ -204,9 +211,12 @@ fn a_replace_deletes_then_makes_and_an_undo_reverses_it() {
Some(vec![CONCLUSION.to_string()])
);
let sync = home.sync.lock().unwrap();
assert_eq!(sync.ledger().conclusion(Workspace::Trusted, "new"), None);
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "old"),
sync.ledger().conclusion(Workspace::Trusted, "kyle", "new"),
None
);
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "kyle", "old"),
Some((edit("e3", 1), CONCLUSION.to_string()))
);
}
@@ -238,6 +248,57 @@ fn a_deletion_with_nothing_to_delete_is_sent_with_no_id() {
);
}
#[test]
fn the_same_text_about_another_peer_is_not_deleted() {
// "Uses tabs." is in user.md (about the owner) and in memory.md (about the agent); the
// user.md entry is removed: only the owner's conclusion goes.
let fake = FakeHoncho::start();
let home = Home::new("edits-peers");
decide(&home, edit("e1", 0), conclude("kyle", "Uses tabs."));
decide(&home, edit("e2", 0), conclude("boxmaker0", "Uses tabs."));
decide(&home, edit("e3", 0), unconclude_about("kyle", "Uses tabs."));
let mut w = home.worker(&fake, FAST);
for _ in 0..3 {
assert_eq!(w.step(), Step::Done);
}
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((edit("e2", 0), CONCLUSION.to_string()))
);
assert_eq!(
sent_ids(&home, &edit("e3", 0)),
Some(vec![CONCLUSION.to_string()])
);
// Nothing about the owner is left to delete for this text any more.
drop(sync);
decide(&home, edit("e4", 0), unconclude_about("kyle", "Uses tabs."));
fake.clear();
assert_eq!(w.step(), Step::Done);
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
// The memory.md entry removed: the agent's conclusion goes.
decide(
&home,
edit("e5", 0),
unconclude_about("boxmaker0", "Uses tabs."),
);
assert_eq!(w.step(), Step::Done);
assert_eq!(fake.requests().len(), 1, "{:?}", fake.requests());
assert_eq!(fake.requests()[0].method, "DELETE");
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
None
);
}
#[test]
fn a_404_on_delete_is_done() {
let fake = FakeHoncho::start();
+15 -15
View File
@@ -41,7 +41,7 @@ fn examples() -> Vec<(Record, &'static str)> {
&edit("2026-09-28-0002", 1),
unconclude(Workspace::Trusted, "Kyle takes his coffee black."),
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","text":"Kyle takes his coffee black."}}"#,
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","peer":"kyle","text":"Kyle takes his coffee black."}}"#,
),
(
decided(
@@ -329,11 +329,11 @@ fn a_conclusion_is_the_latest_one_not_undone() {
ledger
.append(decided(&first, conclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", text), None);
ledger.append(sent(&first, &["c-1"])).unwrap();
let found = |id: &str, item: &Item| Some((item.clone(), id.to_string()));
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-1", &first)
);
ledger
@@ -341,26 +341,26 @@ fn a_conclusion_is_the_latest_one_not_undone() {
.unwrap();
ledger.append(sent(&second, &["c-2"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-2", &second)
);
ledger
.append(decided(&undo1, unconclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-2", &second)
);
ledger.append(sent(&undo1, &[])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-1", &first)
);
ledger
.append(decided(&undo2, unconclude(Workspace::Trusted, text)))
.unwrap();
ledger.append(sent(&undo2, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", text), None);
// The latest is the one sent last, not the one decided last.
let (early, late) = (edit("2026-09-28-0005", 0), edit("2026-09-28-0006", 0));
for item in [&early, &late] {
@@ -371,7 +371,7 @@ fn a_conclusion_is_the_latest_one_not_undone() {
ledger.append(sent(&late, &["c-late"])).unwrap();
ledger.append(sent(&early, &["c-early"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "y"),
ledger.conclusion(Workspace::Trusted, "kyle", "y"),
found("c-early", &early)
);
}
@@ -404,14 +404,14 @@ fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() {
ledger.append(sent(&other, &["c-other"])).unwrap();
ledger.append(sent(&undo_elsewhere, &[])).unwrap();
let a = Some((sent_one.clone(), "c-a".to_string()));
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), a);
assert_eq!(ledger.conclusion(Workspace::Trusted, "b"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "c"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "A"), None);
assert_eq!(ledger.conclusion(Workspace::Untrusted, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Fleet, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "a"), a);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "b"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "c"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "A"), None);
assert_eq!(ledger.conclusion(Workspace::Untrusted, "kyle", "a"), None);
assert_eq!(ledger.conclusion(Workspace::Fleet, "kyle", "a"), None);
ledger.append(sent(&undo_unsent, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "a"), None);
}
#[test]
+152
View File
@@ -0,0 +1,152 @@
//! When sync began (M5c spec, section 5): the ledger's first line, `began`, once; a ledger read
//! back that does not start with it, or has a second, is damaged; and conclusions matched by their
//! peer as well as their workspace and text. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
#[path = "support/tmp.rs"]
mod tmp;
use ledger::{
Home, TIME, began, began_line, conclude, decided, edit, line, sent, time, to_trusted, turn,
};
use memoryd::honcho::ledger::{Action, Ledger, LedgerError, Workspace};
const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged";
#[test]
fn the_began_line() {
assert_eq!(
serde_json::to_string(&began()).unwrap(),
format!(r#"{{"type":"began","time":"{TIME}"}}"#)
);
assert_eq!(
began_line(),
format!("{{\"type\":\"began\",\"time\":\"{TIME}\"}}\n")
);
}
#[test]
fn began_is_kept_and_read_back() {
let home = Home::new("began-kept");
let fresh = home.open();
assert_eq!(fresh.began(), None);
let mut ledger = home.begun();
assert_eq!(ledger.began(), Some(time()));
ledger
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
.unwrap();
assert_eq!(home.text().lines().next(), Some(began_line().trim_end()));
let again = home.open();
assert_eq!(again.began(), Some(time()));
assert_eq!(again.counts().decided, 1, "began is no item");
}
#[test]
fn began_only_first_and_once() {
let home = Home::new("began-once");
let mut ledger = home.begun();
let err = ledger.append(began()).unwrap_err();
assert!(matches!(err, LedgerError::BeganNotFirst), "{err:?}");
assert_eq!(err.to_string(), "sync began, but not on the first line");
let home = Home::new("began-late");
let mut ledger = home.open();
ledger
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
.unwrap();
assert!(matches!(
ledger.append(began()),
Err(LedgerError::BeganNotFirst)
));
}
/// `open`'s error for `text`: its line and its display.
fn damaged(tag: &str, text: &str) -> (usize, String, Home) {
let home = Home::new(tag);
home.write(text);
let err = Ledger::open(&home.path).unwrap_err();
let shown = err.to_string();
match err {
LedgerError::Damaged { line, .. } => (line, shown, home),
other => panic!("not Damaged: {other:?}"),
}
}
#[test]
fn a_ledger_that_does_not_start_with_began_is_damaged() {
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let (number, shown, home) = damaged("began-missing", &first);
assert_eq!(number, 1);
assert_eq!(
shown,
format!(
"{}:1: the first line is not when sync began{POINTER}",
home.path.display()
)
);
let twice = format!("{}{first}{}", began_line(), began_line());
let (number, shown, home) = damaged("began-twice", &twice);
assert_eq!(number, 3);
assert_eq!(
shown,
format!(
"{}:3: sync began, but not on the first line{POINTER}",
home.path.display()
)
);
// An empty ledger has no line to be wrong: it is empty.
let home = Home::new("began-empty");
home.write("");
assert_eq!(home.open().began(), None);
}
fn unconclude_about(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
#[test]
fn a_conclusion_is_found_by_its_peer_too() {
let home = Home::new("began-peer");
let mut ledger = home.begun();
let (owner, agent, undo) = (edit("e1", 0), edit("e2", 0), edit("e3", 0));
ledger
.append(decided(&owner, conclude(Workspace::Trusted, "Uses tabs.")))
.unwrap();
let mut about_agent = conclude(Workspace::Trusted, "Uses tabs.");
if let Action::Conclude { peer, .. } = &mut about_agent {
*peer = "boxmaker0".to_string();
}
ledger.append(decided(&agent, about_agent)).unwrap();
ledger.append(sent(&owner, &["c-owner"])).unwrap();
ledger.append(sent(&agent, &["c-agent"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
Some((owner.clone(), "c-owner".to_string()))
);
assert_eq!(
ledger.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((agent.clone(), "c-agent".to_string()))
);
// The owner's is undone: the agent's stays.
ledger
.append(decided(&undo, unconclude_about("kyle", "Uses tabs.")))
.unwrap();
ledger.append(sent(&undo, &["c-owner"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
assert_eq!(
ledger.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((agent, "c-agent".to_string()))
);
let again = home.open();
assert_eq!(
again.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
}
+26 -15
View File
@@ -1,6 +1,7 @@
//! The Honcho ledger's file (M5c spec, section 5): read through at start, a line cut off by a
//! crash dropped and cut from the file, a damaged line refused with its number and the runbook
//! pointer, the file's mode, and a failed write that changes nothing. Do not edit.
//! pointer, the file's mode, and a failed write that changes nothing. From M5c-1 task 15 every
//! ledger that is read back starts with when sync began. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
@@ -10,7 +11,8 @@ mod tmp;
use std::os::unix::fs::PermissionsExt;
use ledger::{
Home, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude,
Home, began_line, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn,
unconclude,
};
use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace};
@@ -56,7 +58,7 @@ fn an_empty_file_is_an_empty_ledger() {
#[test]
fn reopening_reads_the_ledger_through() {
let home = Home::new("ledger-reopen");
let mut ledger = home.open();
let mut ledger = home.begun();
let (a, b, c, d, e) = (
turn("chat-1", 1),
turn("chat-1", 2),
@@ -81,7 +83,7 @@ fn reopening_reads_the_ledger_through() {
assert_eq!(again.counts(), ledger.counts());
assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..]));
assert_eq!(
again.conclusion(Workspace::Trusted, "x"),
again.conclusion(Workspace::Trusted, "kyle", "x"),
Some((c.clone(), "c-1".to_string()))
);
for item in [&a, &b, &c, &d, &e] {
@@ -111,7 +113,8 @@ fn reopening_reads_the_ledger_through() {
fn a_cut_off_last_line_is_dropped_and_cut_from_the_file() {
let (a, b, c) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3));
let whole = format!(
"{}{}",
"{}{}{}",
began_line(),
line(&decided(&a, to_trusted("kyle"))),
line(&decided(&b, to_trusted("kyle")))
);
@@ -150,7 +153,11 @@ fn a_cut_off_first_line_leaves_an_empty_file() {
fn a_line_cut_inside_a_character_is_dropped_too() {
let home = Home::new("ledger-torn-utf8");
let a = edit("2026-09-28-0001", 0);
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let first = format!(
"{}{}",
began_line(),
line(&decided(&turn("chat-1", 1), to_trusted("kyle")))
);
let second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café")));
let at = second.find('é').unwrap() + 1;
let mut bytes = first.clone().into_bytes();
@@ -175,11 +182,11 @@ fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() {
] {
for tail in ["", "{\"type\":\"se"] {
let home = Home::new("ledger-damaged");
let text = format!("{first}{second}{third}{tail}");
let text = format!("{}{first}{second}{third}{tail}", began_line());
home.write(&text);
let (number, shown) = damaged(&home);
assert_eq!(number, 2, "{second}");
let start = format!("{}:2: ", home.path.display());
assert_eq!(number, 3, "{second}");
let start = format!("{}:3: ", home.path.display());
assert!(shown.starts_with(&start), "{shown}");
assert!(shown.contains(why), "{shown}");
assert!(shown.ends_with(POINTER), "{shown}");
@@ -194,17 +201,17 @@ fn a_line_that_breaks_the_rules_is_damage() {
let cases = [
(
vec![sent(&a, &["m"])],
1,
2,
"turn 1 of session chat-1: not waiting to be sent",
),
(
vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))],
2,
3,
"turn 1 of session chat-1: decided already",
),
(
vec![decided(&b, skip("flagged")), failed(&b, Some(400))],
2,
3,
"part 2 of edit 2026-09-28-0001: not waiting to be sent",
),
(
@@ -214,13 +221,17 @@ fn a_line_that_breaks_the_rules_is_damage() {
decided(&b, to_trusted("kyle")),
failed(&a, None),
],
4,
5,
"turn 1 of session chat-1: not waiting to be sent",
),
];
for (records, number, why) in cases {
let home = Home::new("ledger-rules");
let text: String = records.iter().map(line).collect();
let text = format!(
"{}{}",
began_line(),
records.iter().map(line).collect::<String>()
);
home.write(&text);
let (got, shown) = damaged(&home);
assert_eq!(got, number, "{text}");
@@ -266,7 +277,7 @@ fn a_missing_directory_is_an_io_error_and_changes_nothing() {
#[test]
fn a_failed_write_changes_nothing_in_memory() {
let home = Home::new("ledger-failed-write");
let mut ledger = home.open();
let mut ledger = home.begun();
let (a, b) = (turn("chat-1", 1), turn("chat-1", 2));
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
let kept = home.text();
+25 -8
View File
@@ -69,9 +69,10 @@ fn conclude(peer: &str, text: &str) -> Action {
}
}
fn unconclude(text: &str) -> Action {
fn unconclude(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
@@ -139,13 +140,16 @@ fn a_replace_deletes_the_old_then_makes_the_new() {
&["x", "old"],
&["x", "new"],
);
assert_eq!(of(&r), vec![unconclude("old"), conclude(OWNER_PEER, "new")]);
assert_eq!(
of(&r),
vec![unconclude(OWNER_PEER, "old"), conclude(OWNER_PEER, "new")]
);
}
#[test]
fn a_remove_deletes() {
let r = record("e1", "remove", Some("memory.md"), &["x", "gone"], &["x"]);
assert_eq!(of(&r), vec![unconclude("gone")]);
assert_eq!(of(&r), vec![unconclude(AGENT, "gone")]);
let reorder = record(
"e2",
"consolidate",
@@ -156,6 +160,15 @@ fn a_remove_deletes() {
assert!(of(&reorder).is_empty());
}
#[test]
fn a_deletion_names_the_peer_of_its_file() {
// The same text in both files: each file's removal deletes its own peer's conclusion.
let user = record("e1", "remove", Some("user.md"), &["Uses tabs."], &[]);
assert_eq!(of(&user), vec![unconclude(OWNER_PEER, "Uses tabs.")]);
let memory = record("e2", "remove", Some("memory.md"), &["Uses tabs."], &[]);
assert_eq!(of(&memory), vec![unconclude(AGENT, "Uses tabs.")]);
}
#[test]
fn a_consolidate_deletes_the_dropped_and_not_the_moved() {
let mut r = record(
@@ -172,8 +185,8 @@ fn a_consolidate_deletes_the_dropped_and_not_the_moved() {
assert_eq!(
of(&r),
vec![
unconclude("old"),
unconclude("dropped"),
unconclude(AGENT, "old"),
unconclude(AGENT, "dropped"),
conclude(AGENT, "rewritten"),
conclude(AGENT, "new"),
]
@@ -224,11 +237,15 @@ fn notes_by_trust_and_flag() {
fn an_undo_is_the_same_diff_backwards() {
// An undo's before is the file now, its after the undone edit's before.
let cases = [
(&["x", "added"][..], &["x"][..], vec![unconclude("added")]),
(
&["x", "added"][..],
&["x"][..],
vec![unconclude(OWNER_PEER, "added")],
),
(
&["x", "new"][..],
&["x", "old"][..],
vec![unconclude("new"), conclude(OWNER_PEER, "old")],
vec![unconclude(OWNER_PEER, "new"), conclude(OWNER_PEER, "old")],
),
(
&["x"][..],
@@ -268,7 +285,7 @@ fn an_owners_undo_carries_the_undone_edits_session() {
assert_eq!(
actions(&replace_undo, None, AGENT, OWNER_PEER),
vec![
unconclude("new"),
unconclude(OWNER_PEER, "new"),
Action::Skip {
why: NO_SESSION.to_string()
}
@@ -140,9 +140,11 @@ fn conclude(peer: &str, session: &str, text: &str) -> Action {
}
}
/// The deletion of the owner's conclusion `text`: every one these tests make is from `user.md`.
fn unconclude(text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: "kyle".to_string(),
text: text.to_string(),
}
}
+6
View File
@@ -45,6 +45,12 @@ fn hs(name: &str, speakers: &[&str]) -> Option<HonchoSession> {
fn ledger(dir: &TempDir, decisions: &[(Item, Action)]) -> Ledger {
let path = dir.path().join("honcho.jsonl");
let mut ledger = Ledger::open(&path).unwrap();
// Every ledger begins with when sync began (M5c-1 task 15).
ledger
.append(Record::Began {
time: Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap(),
})
.unwrap();
for (item, action) in decisions {
ledger
.append(Record::Decided {
+19 -6
View File
@@ -42,6 +42,9 @@ fleet = \"fleet\"
const DECIDED: &str = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"mm-a","turn":1},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#;
/// When sync began: every ledger's first line (M5c-1 task 15).
const BEGAN: &str = r#"{"type":"began","time":"2026-09-28T09:00:00.000Z"}"#;
/// A turn decided to go nowhere: the worker has nothing to send for it.
const SKIPPED: &str = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"mm-a","turn":1},"action":{"type":"skip","why":"the turn has no single speaker"}}"#;
@@ -142,10 +145,12 @@ fn a_reported_turn_is_decided_after_loopd_has_its_answer() {
let (answer, _) = updated(&home, "mm-a", 1);
assert!(matches!(answer, Message::Ok(_)), "{answer:?}");
assert!(within_10s(|| home.ledger_text().contains("\"turn\":1}")));
// The line as `DECIDED`, but for its time, which is the decision's.
// The line after the began line (M5c-1 task 15): as `DECIDED`, but for its time, which is the
// decision's.
let text = home.ledger_text();
assert_eq!(text.lines().count(), 1, "{text}");
let mut got: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(text.lines().count(), 2, "{text}");
assert!(text.starts_with(r#"{"type":"began","#), "{text}");
let mut got: serde_json::Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
let mut want: serde_json::Value = serde_json::from_str(DECIDED).unwrap();
got["time"] = serde_json::Value::Null;
want["time"] = serde_json::Value::Null;
@@ -196,7 +201,11 @@ fn a_silent_brokerd_holds_up_neither_loopd_nor_memory() {
assert_eq!(outage.len(), 1, "{logged:?}");
let start = format!("memoryd: {}: ", home.config.state_socket().display());
assert!(outage[0].starts_with(&start), "{}", outage[0]);
assert_eq!(home.ledger_text(), "");
assert!(
!home.ledger_text().contains("decided"),
"{}",
home.ledger_text()
);
}
#[test]
@@ -267,7 +276,11 @@ fn a_damaged_ledger_stops_the_start() {
fn a_cut_off_last_line_is_dropped_and_logged() {
let home = Home::new("serve-repaired", true);
std::fs::create_dir_all(home.dir.path().join("memory")).unwrap();
std::fs::write(home.ledger(), format!("{SKIPPED}\n{{\"type\":\"se")).unwrap();
std::fs::write(
home.ledger(),
format!("{BEGAN}\n{SKIPPED}\n{{\"type\":\"se"),
)
.unwrap();
let lines = home.serve();
assert_eq!(
*lines.lock().unwrap(),
@@ -276,7 +289,7 @@ fn a_cut_off_last_line_is_dropped_and_logged() {
home.ledger().display()
)]
);
assert_eq!(home.ledger_text(), format!("{SKIPPED}\n"));
assert_eq!(home.ledger_text(), format!("{BEGAN}\n{SKIPPED}\n"));
}
/// A `memoryd` process, killed and waited for when dropped: declared after its `Home`, so it
+1
View File
@@ -48,6 +48,7 @@ fn to_trusted() -> Action {
/// 1 skipped.
fn mixed(dir: &TempDir) -> Ledger {
let mut l = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
l.append(Record::Began { time: at(0) }).unwrap();
let decided = |item: Item, action: Action| Record::Decided {
time: at(0),
item,
+9 -3
View File
@@ -16,7 +16,7 @@ use std::cell::RefCell;
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply, Timestamp};
use logs::{assistant, write_log};
use memoryd::honcho::decide::{NO_REPLY, NO_SPEAKER, SECRET_CLASS};
use memoryd::honcho::ledger::{Action, Item, Ledger, Workspace};
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
use memoryd::honcho::turns::{
LEDGER, LOG_RUNBOOK, TurnSync, decidable, finished, log_path, read_log,
};
@@ -39,7 +39,10 @@ impl World {
fn new(tag: &str) -> World {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("memory")).unwrap();
let ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
let mut ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
// Every ledger begins with when sync began (M5c-1 task 15), before these tests' turns.
let began = Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap();
ledger.append(Record::Began { time: began }).unwrap();
World {
sync: TurnSync::new(ledger, "boxmaker0", &dir.path().join("sessions")),
answer: RefCell::new(Ok(clean())),
@@ -283,7 +286,7 @@ fn nothing_undecided_asks_nothing() {
w.updated("mm-a", 1);
assert_eq!(*w.asked.borrow(), vec!["mm-a"]);
let lines = std::fs::read_to_string(w.dir.path().join("memory").join(LEDGER)).unwrap();
assert_eq!(lines.lines().count(), 1);
assert_eq!(lines.lines().count(), 2, "the began line and one decision");
// A restart reads the ledger through: nothing is decided twice.
let ledger = Ledger::open(&w.dir.path().join("memory").join(LEDGER)).unwrap();
w.sync = TurnSync::new(ledger, "boxmaker0", &w.dir.path().join("sessions"));
@@ -356,6 +359,8 @@ fn a_ledger_that_cannot_be_written_stops_the_walk() {
let mut w = World::new("turns-ledger");
w.log("mm-a", &[turns::finished(Some("kyle"))]);
let ledger = w.dir.path().join("memory").join(LEDGER);
let kept = std::fs::read_to_string(&ledger).unwrap();
std::fs::remove_file(&ledger).unwrap();
std::fs::create_dir(&ledger).unwrap();
w.updated("mm-a", 1);
let lines = w.lines.borrow().clone();
@@ -368,6 +373,7 @@ fn a_ledger_that_cannot_be_written_stops_the_walk() {
}));
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 1)]);
std::fs::remove_dir(&ledger).unwrap();
std::fs::write(&ledger, kept).unwrap();
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle"));
}
+5 -1
View File
@@ -156,7 +156,11 @@ fn a_retraction_waits() {
let fake = FakeHoncho::start();
let mut home = Home::new("worker-later");
let retract = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"retract","session":"mm-a","text":"a note"}}"#;
std::fs::write(home.ledger_path(), format!("{retract}\n")).unwrap();
std::fs::write(
home.ledger_path(),
format!("{}\n{retract}\n", worker::began_line()),
)
.unwrap();
home.restart();
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Idle);
+20
View File
@@ -28,6 +28,14 @@ impl Home {
Ledger::open(&self.path).unwrap()
}
/// A new ledger that has begun (`began()`): what `memoryd` makes at its first start, and so
/// what a test that reads its ledger back needs (M5c-1 task 15).
pub fn begun(&self) -> Ledger {
let mut ledger = self.open();
ledger.append(began()).unwrap();
ledger
}
/// The file's bytes as text; "" when there is no file.
pub fn text(&self) -> String {
std::fs::read_to_string(&self.path).unwrap_or_default()
@@ -85,13 +93,25 @@ pub fn conclude(workspace: Workspace, text: &str) -> Action {
}
}
/// The deletion of the owner's (`kyle`'s) conclusion `text`, as `conclude` makes it.
pub fn unconclude(workspace: Workspace, text: &str) -> Action {
Action::Unconclude {
workspace,
peer: "kyle".to_string(),
text: text.to_string(),
}
}
/// When sync began, `TIME`.
pub fn began() -> Record {
Record::Began { time: time() }
}
/// `began()`'s line.
pub fn began_line() -> String {
line(&began())
}
pub fn decided(item: &Item, action: Action) -> Record {
Record::Decided {
time: time(),
+9 -3
View File
@@ -4,9 +4,9 @@
use std::path::Path;
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply};
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply, Timestamp};
use crate::logs::{assistant, at, result};
use crate::logs::{assistant, result};
pub fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
@@ -19,9 +19,15 @@ pub fn clean() -> SessionStateReply {
}
}
/// A speaker's message. Its time is the last one there is, so that it is after any time sync
/// began (M5c-1 task 15); `spoken_at` gives another.
pub fn spoken(content: &str, speaker: Option<&str>) -> LogRecord {
spoken_at(Timestamp::MAX, content, speaker)
}
pub fn spoken_at(time: Timestamp, content: &str, speaker: Option<&str>) -> LogRecord {
LogRecord::User {
time: at(1),
time,
content: content.to_string(),
speaker: speaker.map(str::to_string),
}
+18 -2
View File
@@ -28,6 +28,18 @@ pub const FAST: Tuning = Tuning {
idle: Duration::from_millis(1),
};
/// When the tests' ledgers began: before every turn they log (`spoken`).
pub const BEGAN: &str = "2026-09-28T09:00:00.000Z";
pub fn began() -> Timestamp {
Timestamp::parse(BEGAN).unwrap()
}
/// The ledger line of `BEGAN`, for a test that writes a ledger itself.
pub fn began_line() -> String {
format!(r#"{{"type":"began","time":"{BEGAN}"}}"#)
}
pub fn config(url: &str) -> HonchoConfig {
HonchoConfig {
url: url.to_string(),
@@ -50,12 +62,15 @@ pub struct Home {
}
impl Home {
/// A home whose ledger has begun, at `BEGAN`, as `memoryd` makes it at its first start.
pub fn new(tag: &str) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("memory")).unwrap();
let mut ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
ledger.append(Record::Began { time: began() }).unwrap();
Home {
sync: Arc::new(Mutex::new(TurnSync::new(
Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap(),
ledger,
"boxmaker0",
&dir.path().join("sessions"),
))),
@@ -118,12 +133,13 @@ impl Home {
)
}
/// Every record of the ledger on disk, in order.
/// Every record of the ledger on disk but its `Began`, in order.
pub fn records(&self) -> Vec<Record> {
std::fs::read_to_string(self.ledger_path())
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.filter(|r| !matches!(r, Record::Began { .. }))
.collect()
}