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:
@@ -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));
|
||||
}
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user