memoryd: the Honcho ledger, decided, sent and failed, read through at start
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
//! The Honcho ledger, `memory/honcho.jsonl` (M5c spec, section 5): its lines, exactly, and the
|
||||
//! rules of `append`, the outbox, the sent ids, the live conclusions and the counts. Reading the
|
||||
//! file back is in `honcho_ledger_file.rs`. Do not edit.
|
||||
|
||||
#[path = "support/ledger.rs"]
|
||||
mod ledger;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use ledger::{
|
||||
Home, conclude, decided, edit, failed, sent, sid, skip, to_trusted, turn, unconclude,
|
||||
};
|
||||
use memoryd::honcho::ledger::{Action, Counts, Item, LedgerError, Record, Workspace};
|
||||
|
||||
/// One record of each kind, and of each action, with its line exactly.
|
||||
fn examples() -> Vec<(Record, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
decided(&turn("chat-1", 3), to_trusted("kyle")),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#,
|
||||
),
|
||||
(
|
||||
decided(
|
||||
&turn("chat-2", 1),
|
||||
Action::Turn {
|
||||
workspace: Workspace::Untrusted,
|
||||
speaker: "ana_b".to_string(),
|
||||
},
|
||||
),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"action":{"type":"turn","workspace":"untrusted","speaker":"ana_b"}}"#,
|
||||
),
|
||||
(
|
||||
decided(
|
||||
&edit("2026-09-28-0001", 0),
|
||||
conclude(Workspace::Trusted, "Kyle takes his coffee black."),
|
||||
),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"chat-1","text":"Kyle takes his coffee black."}}"#,
|
||||
),
|
||||
(
|
||||
decided(
|
||||
&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."}}"#,
|
||||
),
|
||||
(
|
||||
decided(
|
||||
&edit("2026-09-28-0003", 0),
|
||||
Action::Fleet {
|
||||
session: sid("chat-2"),
|
||||
text: "The build host is \"straylight\".\nIt runs NixOS.".to_string(),
|
||||
},
|
||||
),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0003","part":0},"action":{"type":"fleet","session":"chat-2","text":"The build host is \"straylight\".\nIt runs NixOS."}}"#,
|
||||
),
|
||||
(
|
||||
decided(
|
||||
&edit("2026-09-28-0004", 0),
|
||||
Action::Retract {
|
||||
session: sid("chat-2"),
|
||||
text: "The build host is titan.".to_string(),
|
||||
},
|
||||
),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0004","part":0},"action":{"type":"retract","session":"chat-2","text":"The build host is titan."}}"#,
|
||||
),
|
||||
(
|
||||
decided(&turn("chat-3", 7), skip("the session is secret")),
|
||||
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-3","turn":7},"action":{"type":"skip","why":"the session is secret"}}"#,
|
||||
),
|
||||
(
|
||||
sent(&turn("chat-1", 3), &["msg-a1", "msg-a2"]),
|
||||
r#"{"type":"sent","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"ids":["msg-a1","msg-a2"]}"#,
|
||||
),
|
||||
(
|
||||
failed(&edit("2026-09-28-0001", 0), Some(422)),
|
||||
r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"status":422,"kind":"refused"}"#,
|
||||
),
|
||||
(
|
||||
failed(&turn("chat-2", 1), None),
|
||||
r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"status":null,"kind":"refused"}"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_record_is_one_line_exactly_as_given() {
|
||||
for (record, text) in examples() {
|
||||
assert_eq!(serde_json::to_string(&record).unwrap(), text);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Record>(text).unwrap(),
|
||||
record,
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for (workspace, text) in [
|
||||
(Workspace::Trusted, "\"trusted\""),
|
||||
(Workspace::Untrusted, "\"untrusted\""),
|
||||
(Workspace::Fleet, "\"fleet\""),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&workspace).unwrap(), text);
|
||||
assert_eq!(serde_json::from_str::<Workspace>(text).unwrap(), workspace);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_writes_the_line_and_a_newline() {
|
||||
let home = Home::new("ledger-append");
|
||||
let mut ledger = home.open();
|
||||
let (first, second) = (examples().remove(0), examples().remove(7));
|
||||
ledger.append(first.0).unwrap();
|
||||
assert_eq!(home.text(), format!("{}\n", first.1));
|
||||
ledger.append(second.0).unwrap();
|
||||
assert_eq!(home.text(), format!("{}\n{}\n", first.1, second.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_field_or_value_is_refused() {
|
||||
for (_, text) in examples() {
|
||||
let mut broken = vec![format!("{},\"extra\":1}}", &text[..text.len() - 1])];
|
||||
broken.push(text.replacen("\"item\":{", "\"item\":{\"extra\":1,", 1));
|
||||
if text.contains("\"action\":{") {
|
||||
broken.push(text.replacen("\"action\":{", "\"action\":{\"extra\":1,", 1));
|
||||
}
|
||||
for line in broken {
|
||||
let err = serde_json::from_str::<Record>(&line).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("unknown field `extra`"),
|
||||
"{line}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let (_, text) = examples().remove(0);
|
||||
for (from, to) in [
|
||||
("\"trusted\"", "\"shared\""),
|
||||
("\"type\":\"decided\"", "\"type\":\"queued\""),
|
||||
(
|
||||
"\"type\":\"turn\",\"session\"",
|
||||
"\"type\":\"call\",\"session\"",
|
||||
),
|
||||
("\"session\":\"chat-1\"", "\"session\":\"Chat 1\""),
|
||||
(
|
||||
"\"time\":\"2026-09-28T10:00:00.000Z\"",
|
||||
"\"time\":\"today\"",
|
||||
),
|
||||
] {
|
||||
assert_eq!(text.matches(from).count(), 1, "{from}");
|
||||
let line = text.replace(from, to);
|
||||
assert!(serde_json::from_str::<Record>(&line).is_err(), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outbox_is_what_is_decided_and_not_skipped_sent_or_failed() {
|
||||
let home = Home::new("ledger-outbox");
|
||||
let mut ledger = home.open();
|
||||
let (a, b, c, d, e) = (
|
||||
turn("chat-1", 1),
|
||||
turn("chat-1", 2),
|
||||
edit("2026-09-28-0001", 0),
|
||||
edit("2026-09-28-0002", 0),
|
||||
turn("chat-1", 3),
|
||||
);
|
||||
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
|
||||
ledger.append(decided(&b, skip("no speaker"))).unwrap();
|
||||
ledger
|
||||
.append(decided(&c, conclude(Workspace::Trusted, "one")))
|
||||
.unwrap();
|
||||
ledger
|
||||
.append(decided(&d, conclude(Workspace::Trusted, "two")))
|
||||
.unwrap();
|
||||
ledger.append(decided(&e, to_trusted("ana_b"))).unwrap();
|
||||
ledger.append(sent(&c, &["c-1"])).unwrap();
|
||||
ledger.append(failed(&d, Some(400))).unwrap();
|
||||
assert_eq!(
|
||||
ledger.outbox(),
|
||||
vec![
|
||||
(a.clone(), to_trusted("kyle")),
|
||||
(e.clone(), to_trusted("ana_b"))
|
||||
]
|
||||
);
|
||||
for item in [&a, &b, &c, &d, &e] {
|
||||
assert!(ledger.is_decided(item), "{item:?}");
|
||||
}
|
||||
assert!(!ledger.is_decided(&turn("chat-1", 4)));
|
||||
assert!(!ledger.is_decided(&edit("2026-09-28-0001", 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outbox_keeps_the_order_of_decision() {
|
||||
let home = Home::new("ledger-order");
|
||||
let mut ledger = home.open();
|
||||
let items = [
|
||||
turn("chat-b", 2),
|
||||
turn("chat-a", 9),
|
||||
edit("2026-09-28-0002", 0),
|
||||
turn("chat-b", 1),
|
||||
edit("2026-09-28-0001", 3),
|
||||
edit("2026-09-28-0001", 0),
|
||||
];
|
||||
for item in &items {
|
||||
ledger.append(decided(item, skip("placeholder"))).unwrap();
|
||||
}
|
||||
assert!(ledger.outbox().is_empty());
|
||||
let home = Home::new("ledger-order-2");
|
||||
let mut ledger = home.open();
|
||||
for item in &items {
|
||||
ledger.append(decided(item, to_trusted("kyle"))).unwrap();
|
||||
}
|
||||
let order: Vec<Item> = ledger.outbox().into_iter().map(|(item, _)| item).collect();
|
||||
assert_eq!(order, items.to_vec());
|
||||
ledger.append(sent(&items[1], &["m"])).unwrap();
|
||||
let order: Vec<Item> = ledger.outbox().into_iter().map(|(item, _)| item).collect();
|
||||
let mut left = items.to_vec();
|
||||
left.remove(1);
|
||||
assert_eq!(order, left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_decision_is_refused_and_writes_nothing() {
|
||||
let home = Home::new("ledger-twice");
|
||||
let mut ledger = home.open();
|
||||
let item = turn("chat-1", 1);
|
||||
ledger.append(decided(&item, to_trusted("kyle"))).unwrap();
|
||||
let before = home.text();
|
||||
for action in [to_trusted("kyle"), skip("again")] {
|
||||
let err = ledger.append(decided(&item, action)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, LedgerError::AlreadyDecided(i) if *i == item),
|
||||
"{err:?}"
|
||||
);
|
||||
assert_eq!(err.to_string(), "turn 1 of session chat-1: decided already");
|
||||
}
|
||||
assert_eq!(home.text(), before);
|
||||
assert_eq!(ledger.outbox(), vec![(item, to_trusted("kyle"))]);
|
||||
assert_eq!(ledger.counts().decided, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_or_failed_before_the_decision_is_refused() {
|
||||
let home = Home::new("ledger-undecided");
|
||||
let mut ledger = home.open();
|
||||
let item = edit("2026-09-28-0001", 2);
|
||||
for record in [sent(&item, &["x"]), failed(&item, Some(404))] {
|
||||
let err = ledger.append(record).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, LedgerError::NotWaiting(i) if *i == item),
|
||||
"{err:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"part 2 of edit 2026-09-28-0001: not waiting to be sent"
|
||||
);
|
||||
}
|
||||
assert!(!home.path.exists(), "a refused append wrote the file");
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert_eq!(ledger.sent_ids(&item), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_is_sent_or_failed_once_and_never_when_skipped() {
|
||||
let home = Home::new("ledger-once");
|
||||
let mut ledger = home.open();
|
||||
let (s, f, k) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3));
|
||||
ledger.append(decided(&s, to_trusted("kyle"))).unwrap();
|
||||
ledger.append(decided(&f, to_trusted("kyle"))).unwrap();
|
||||
ledger.append(decided(&k, skip("secret"))).unwrap();
|
||||
ledger.append(sent(&s, &["m1", "m2"])).unwrap();
|
||||
ledger.append(failed(&f, None)).unwrap();
|
||||
let before = home.text();
|
||||
for record in [
|
||||
sent(&s, &["m3"]),
|
||||
failed(&s, Some(500)),
|
||||
sent(&f, &["m4"]),
|
||||
failed(&f, Some(400)),
|
||||
sent(&k, &["m5"]),
|
||||
failed(&k, None),
|
||||
] {
|
||||
let err = ledger.append(record.clone()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, LedgerError::NotWaiting(_)),
|
||||
"{record:?}: {err:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(home.text(), before);
|
||||
assert_eq!(
|
||||
ledger.sent_ids(&s),
|
||||
Some(&["m1".to_string(), "m2".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_ids_are_those_of_the_sent_record() {
|
||||
let home = Home::new("ledger-ids");
|
||||
let mut ledger = home.open();
|
||||
let (waiting, done, refused, skipped) = (
|
||||
turn("chat-1", 1),
|
||||
turn("chat-1", 2),
|
||||
turn("chat-1", 3),
|
||||
turn("chat-1", 4),
|
||||
);
|
||||
for item in [&waiting, &done, &refused] {
|
||||
ledger.append(decided(item, to_trusted("kyle"))).unwrap();
|
||||
}
|
||||
ledger
|
||||
.append(decided(&skipped, skip("no speaker")))
|
||||
.unwrap();
|
||||
ledger.append(sent(&done, &["m-1", "m-2"])).unwrap();
|
||||
ledger.append(failed(&refused, Some(413))).unwrap();
|
||||
assert_eq!(
|
||||
ledger.sent_ids(&done),
|
||||
Some(&["m-1".to_string(), "m-2".to_string()][..])
|
||||
);
|
||||
for item in [&waiting, &refused, &skipped, &turn("chat-9", 1)] {
|
||||
assert_eq!(ledger.sent_ids(item), None, "{item:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conclusion_is_the_latest_one_not_undone() {
|
||||
let home = Home::new("ledger-latest");
|
||||
let mut ledger = home.open();
|
||||
let text = "Kyle lives in Oakland.";
|
||||
let (first, second, undo1, undo2) = (
|
||||
edit("2026-09-28-0001", 0),
|
||||
edit("2026-09-28-0002", 0),
|
||||
edit("2026-09-28-0003", 0),
|
||||
edit("2026-09-28-0004", 0),
|
||||
);
|
||||
ledger
|
||||
.append(decided(&first, conclude(Workspace::Trusted, text)))
|
||||
.unwrap();
|
||||
assert_eq!(ledger.conclusion(Workspace::Trusted, 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),
|
||||
found("c-1", &first)
|
||||
);
|
||||
ledger
|
||||
.append(decided(&second, conclude(Workspace::Trusted, text)))
|
||||
.unwrap();
|
||||
ledger.append(sent(&second, &["c-2"])).unwrap();
|
||||
assert_eq!(
|
||||
ledger.conclusion(Workspace::Trusted, text),
|
||||
found("c-2", &second)
|
||||
);
|
||||
ledger
|
||||
.append(decided(&undo1, unconclude(Workspace::Trusted, text)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.conclusion(Workspace::Trusted, text),
|
||||
found("c-2", &second)
|
||||
);
|
||||
ledger.append(sent(&undo1, &[])).unwrap();
|
||||
assert_eq!(
|
||||
ledger.conclusion(Workspace::Trusted, 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);
|
||||
// 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] {
|
||||
ledger
|
||||
.append(decided(item, conclude(Workspace::Trusted, "y")))
|
||||
.unwrap();
|
||||
}
|
||||
ledger.append(sent(&late, &["c-late"])).unwrap();
|
||||
ledger.append(sent(&early, &["c-early"])).unwrap();
|
||||
assert_eq!(
|
||||
ledger.conclusion(Workspace::Trusted, "y"),
|
||||
found("c-early", &early)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() {
|
||||
let home = Home::new("ledger-where");
|
||||
let mut ledger = home.open();
|
||||
let (sent_one, unsent, empty, other, undo_elsewhere, undo_unsent) = (
|
||||
edit("2026-09-28-0001", 0),
|
||||
edit("2026-09-28-0002", 0),
|
||||
edit("2026-09-28-0003", 0),
|
||||
edit("2026-09-28-0004", 0),
|
||||
edit("2026-09-28-0005", 0),
|
||||
edit("2026-09-28-0006", 0),
|
||||
);
|
||||
let actions = [
|
||||
(&sent_one, conclude(Workspace::Trusted, "a")),
|
||||
(&unsent, conclude(Workspace::Trusted, "b")),
|
||||
(&empty, conclude(Workspace::Trusted, "c")),
|
||||
(&other, conclude(Workspace::Untrusted, "a")),
|
||||
(&undo_elsewhere, unconclude(Workspace::Untrusted, "a")),
|
||||
(&undo_unsent, unconclude(Workspace::Trusted, "a")),
|
||||
];
|
||||
for (item, action) in actions {
|
||||
ledger.append(decided(item, action)).unwrap();
|
||||
}
|
||||
ledger.append(sent(&sent_one, &["c-a"])).unwrap();
|
||||
ledger.append(sent(&empty, &[])).unwrap();
|
||||
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);
|
||||
ledger.append(sent(&undo_unsent, &[])).unwrap();
|
||||
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counts_add_up() {
|
||||
let home = Home::new("ledger-counts");
|
||||
let mut ledger = home.open();
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
let items: Vec<Item> = (1..=9).map(|n| turn("chat-1", n)).collect();
|
||||
for (n, item) in items.iter().enumerate() {
|
||||
let action = if n < 2 {
|
||||
skip("secret")
|
||||
} else {
|
||||
to_trusted("kyle")
|
||||
};
|
||||
ledger.append(decided(item, action)).unwrap();
|
||||
}
|
||||
for item in &items[2..5] {
|
||||
ledger.append(sent(item, &["m"])).unwrap();
|
||||
}
|
||||
ledger.append(failed(&items[5], Some(400))).unwrap();
|
||||
let counts = ledger.counts();
|
||||
assert_eq!(
|
||||
counts,
|
||||
Counts {
|
||||
decided: 9,
|
||||
sent: 3,
|
||||
waiting: 3,
|
||||
failed: 1,
|
||||
skipped: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
counts.decided,
|
||||
counts.sent + counts.waiting + counts.failed + counts.skipped
|
||||
);
|
||||
assert_eq!(ledger.outbox().len(), counts.waiting);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! 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.
|
||||
|
||||
#[path = "support/ledger.rs"]
|
||||
mod ledger;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use ledger::{
|
||||
Home, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude,
|
||||
};
|
||||
use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace};
|
||||
|
||||
const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged";
|
||||
|
||||
/// `open`'s error for the file of `home`, which must be `Damaged`: its line and its display.
|
||||
fn damaged(home: &Home) -> (usize, String) {
|
||||
let err = Ledger::open(&home.path).unwrap_err();
|
||||
let shown = err.to_string();
|
||||
match err {
|
||||
LedgerError::Damaged { path, line, .. } => {
|
||||
assert_eq!(path, home.path);
|
||||
(line, shown)
|
||||
}
|
||||
other => panic!("not Damaged: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_an_empty_ledger_and_nothing_is_created() {
|
||||
let home = Home::new("ledger-missing");
|
||||
let ledger = home.open();
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert!(ledger.outbox().is_empty());
|
||||
assert!(!ledger.repaired());
|
||||
assert!(!home.path.exists());
|
||||
let nowhere = home.dir.path().join("memory").join("honcho.jsonl");
|
||||
let ledger = Ledger::open(&nowhere).unwrap();
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert!(!nowhere.parent().unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_file_is_an_empty_ledger() {
|
||||
let home = Home::new("ledger-empty");
|
||||
home.write("");
|
||||
let ledger = home.open();
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert!(!ledger.repaired());
|
||||
assert_eq!(home.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_reads_the_ledger_through() {
|
||||
let home = Home::new("ledger-reopen");
|
||||
let mut ledger = home.open();
|
||||
let (a, b, c, d, e) = (
|
||||
turn("chat-1", 1),
|
||||
turn("chat-1", 2),
|
||||
edit("2026-09-28-0001", 0),
|
||||
edit("2026-09-28-0002", 0),
|
||||
turn("chat-2", 1),
|
||||
);
|
||||
ledger.append(decided(&b, to_trusted("kyle"))).unwrap();
|
||||
ledger.append(decided(&a, skip("no speaker"))).unwrap();
|
||||
ledger
|
||||
.append(decided(&c, conclude(Workspace::Trusted, "x")))
|
||||
.unwrap();
|
||||
ledger
|
||||
.append(decided(&d, unconclude(Workspace::Trusted, "x")))
|
||||
.unwrap();
|
||||
ledger.append(decided(&e, to_trusted("ana_b"))).unwrap();
|
||||
ledger.append(sent(&c, &["c-1"])).unwrap();
|
||||
ledger.append(failed(&d, Some(404))).unwrap();
|
||||
let again = home.open();
|
||||
assert!(!again.repaired());
|
||||
assert_eq!(again.outbox(), ledger.outbox());
|
||||
assert_eq!(again.counts(), ledger.counts());
|
||||
assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..]));
|
||||
assert_eq!(
|
||||
again.conclusion(Workspace::Trusted, "x"),
|
||||
Some((c.clone(), "c-1".to_string()))
|
||||
);
|
||||
for item in [&a, &b, &c, &d, &e] {
|
||||
assert!(again.is_decided(item), "{item:?}");
|
||||
}
|
||||
let mut again = again;
|
||||
let err = again.append(decided(&b, skip("again"))).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::AlreadyDecided(_)), "{err:?}");
|
||||
let err = again.append(sent(&c, &["c-2"])).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::NotWaiting(_)), "{err:?}");
|
||||
again.append(sent(&b, &["m-1", "m-2"])).unwrap();
|
||||
let third = home.open();
|
||||
assert_eq!(third.outbox(), vec![(e, to_trusted("ana_b"))]);
|
||||
assert_eq!(
|
||||
third.counts(),
|
||||
Counts {
|
||||
decided: 5,
|
||||
sent: 2,
|
||||
waiting: 1,
|
||||
failed: 1,
|
||||
skipped: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
"{}{}",
|
||||
line(&decided(&a, to_trusted("kyle"))),
|
||||
line(&decided(&b, to_trusted("kyle")))
|
||||
);
|
||||
let third = line(&sent(&a, &["m-1"]));
|
||||
// Cut in the middle, and one byte short of the newline: a complete record without its
|
||||
// newline is still a cut-off line.
|
||||
for cut in [third.len() / 2, third.len() - 1] {
|
||||
let home = Home::new("ledger-torn");
|
||||
home.write(&format!("{whole}{}", &third[..cut]));
|
||||
let mut ledger = home.open();
|
||||
assert!(ledger.repaired());
|
||||
assert_eq!(home.text(), whole);
|
||||
assert_eq!(ledger.outbox().len(), 2, "the cut-off send counted");
|
||||
assert_eq!(ledger.sent_ids(&a), None);
|
||||
ledger.append(decided(&c, to_trusted("kyle"))).unwrap();
|
||||
ledger.append(sent(&a, &["m-2"])).unwrap();
|
||||
let again = home.open();
|
||||
assert!(!again.repaired());
|
||||
assert_eq!(again.counts().decided, 3);
|
||||
assert_eq!(again.sent_ids(&a), Some(&["m-2".to_string()][..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_off_first_line_leaves_an_empty_file() {
|
||||
let home = Home::new("ledger-torn-first");
|
||||
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
|
||||
home.write(&first[..10]);
|
||||
let ledger = home.open();
|
||||
assert!(ledger.repaired());
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert_eq!(std::fs::metadata(&home.path).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café")));
|
||||
let at = second.find('é').unwrap() + 1;
|
||||
let mut bytes = first.clone().into_bytes();
|
||||
bytes.extend_from_slice(&second.as_bytes()[..at]);
|
||||
std::fs::write(&home.path, &bytes).unwrap();
|
||||
let ledger = home.open();
|
||||
assert!(ledger.repaired());
|
||||
assert_eq!(home.text(), first);
|
||||
assert!(!ledger.is_decided(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() {
|
||||
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
|
||||
let third = line(&decided(&turn("chat-1", 2), to_trusted("kyle")));
|
||||
let extra = first.replacen("\"speaker\"", "\"extra\":1,\"speaker\"", 1);
|
||||
for (second, why) in [
|
||||
("{\"type\":\"decided\"\n".to_string(), "EOF while parsing"),
|
||||
("\n".to_string(), "EOF while parsing"),
|
||||
("not json\n".to_string(), "expected"),
|
||||
(extra, "unknown field `extra`"),
|
||||
] {
|
||||
for tail in ["", "{\"type\":\"se"] {
|
||||
let home = Home::new("ledger-damaged");
|
||||
let text = format!("{first}{second}{third}{tail}");
|
||||
home.write(&text);
|
||||
let (number, shown) = damaged(&home);
|
||||
assert_eq!(number, 2, "{second}");
|
||||
let start = format!("{}:2: ", home.path.display());
|
||||
assert!(shown.starts_with(&start), "{shown}");
|
||||
assert!(shown.contains(why), "{shown}");
|
||||
assert!(shown.ends_with(POINTER), "{shown}");
|
||||
assert_eq!(home.text(), text, "a damaged ledger was changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_breaks_the_rules_is_damage() {
|
||||
let (a, b) = (turn("chat-1", 1), edit("2026-09-28-0001", 2));
|
||||
let cases = [
|
||||
(
|
||||
vec![sent(&a, &["m"])],
|
||||
1,
|
||||
"turn 1 of session chat-1: not waiting to be sent",
|
||||
),
|
||||
(
|
||||
vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))],
|
||||
2,
|
||||
"turn 1 of session chat-1: decided already",
|
||||
),
|
||||
(
|
||||
vec![decided(&b, skip("flagged")), failed(&b, Some(400))],
|
||||
2,
|
||||
"part 2 of edit 2026-09-28-0001: not waiting to be sent",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
decided(&a, to_trusted("kyle")),
|
||||
sent(&a, &["m"]),
|
||||
decided(&b, to_trusted("kyle")),
|
||||
failed(&a, None),
|
||||
],
|
||||
4,
|
||||
"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();
|
||||
home.write(&text);
|
||||
let (got, shown) = damaged(&home);
|
||||
assert_eq!(got, number, "{text}");
|
||||
let expected = format!("{}:{number}: {why}{POINTER}", home.path.display());
|
||||
assert_eq!(shown, expected);
|
||||
assert_eq!(home.text(), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_is_made_0600() {
|
||||
let home = Home::new("ledger-mode");
|
||||
let mut ledger = home.open();
|
||||
ledger
|
||||
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&home.path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "{mode:o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_directory_is_an_io_error_and_changes_nothing() {
|
||||
let home = Home::new("ledger-nodir");
|
||||
let path = home.dir.path().join("memory").join("honcho.jsonl");
|
||||
let mut ledger = Ledger::open(&path).unwrap();
|
||||
let item = turn("chat-1", 1);
|
||||
let err = ledger
|
||||
.append(decided(&item, to_trusted("kyle")))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, LedgerError::Io(p, _) if *p == path),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.starts_with(&format!("{}: ", path.display()))
|
||||
);
|
||||
assert!(!ledger.is_decided(&item));
|
||||
assert_eq!(ledger.counts(), Counts::default());
|
||||
assert!(!path.parent().unwrap().exists(), "the directory was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_changes_nothing_in_memory() {
|
||||
let home = Home::new("ledger-failed-write");
|
||||
let mut ledger = home.open();
|
||||
let (a, b) = (turn("chat-1", 1), turn("chat-1", 2));
|
||||
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
|
||||
let kept = home.text();
|
||||
// The ledger's path turns into a directory: every write to it fails.
|
||||
std::fs::remove_file(&home.path).unwrap();
|
||||
std::fs::create_dir(&home.path).unwrap();
|
||||
let records = [decided(&b, to_trusted("kyle")), sent(&a, &["m-1"])];
|
||||
for record in records.clone() {
|
||||
let err = ledger.append(record).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::Io(..)), "{err:?}");
|
||||
}
|
||||
assert!(!ledger.is_decided(&b));
|
||||
assert_eq!(ledger.sent_ids(&a), None);
|
||||
assert_eq!(ledger.outbox(), vec![(a.clone(), to_trusted("kyle"))]);
|
||||
std::fs::remove_dir(&home.path).unwrap();
|
||||
home.write(&kept);
|
||||
for record in records {
|
||||
ledger.append(record).unwrap();
|
||||
}
|
||||
let outbox: Vec<Item> = ledger.outbox().into_iter().map(|(i, _)| i).collect();
|
||||
assert_eq!(outbox, vec![b]);
|
||||
assert_eq!(home.open().counts().sent, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ledger_that_cannot_be_read_is_an_io_error() {
|
||||
let home = Home::new("ledger-unreadable");
|
||||
std::fs::create_dir(&home.path).unwrap();
|
||||
let err = Ledger::open(&home.path).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, LedgerError::Io(p, _) if *p == home.path),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Records for the Honcho ledger's tests, and a ledger in a directory of its own. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bxcore::{SessionId, Timestamp};
|
||||
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
|
||||
|
||||
use crate::tmp::TempDir;
|
||||
|
||||
/// A ledger file that does not exist yet, in a directory removed on drop.
|
||||
pub struct Home {
|
||||
pub path: PathBuf,
|
||||
pub dir: TempDir,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
pub fn new(tag: &str) -> Home {
|
||||
let dir = TempDir::new(tag);
|
||||
Home {
|
||||
path: dir.path().join("honcho.jsonl"),
|
||||
dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&self) -> Ledger {
|
||||
Ledger::open(&self.path).unwrap()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Writes `text` as the whole file.
|
||||
pub fn write(&self, text: &str) {
|
||||
std::fs::write(&self.path, text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub const TIME: &str = "2026-09-28T10:00:00.000Z";
|
||||
|
||||
pub fn time() -> Timestamp {
|
||||
Timestamp::parse(TIME).unwrap()
|
||||
}
|
||||
|
||||
pub fn sid(s: &str) -> SessionId {
|
||||
SessionId::new(s).unwrap()
|
||||
}
|
||||
|
||||
pub fn turn(session: &str, turn: u64) -> Item {
|
||||
Item::Turn {
|
||||
session: sid(session),
|
||||
turn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit(id: &str, part: u32) -> Item {
|
||||
Item::Edit {
|
||||
id: id.to_string(),
|
||||
part,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_trusted(speaker: &str) -> Action {
|
||||
Action::Turn {
|
||||
workspace: Workspace::Trusted,
|
||||
speaker: speaker.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skip(why: &str) -> Action {
|
||||
Action::Skip {
|
||||
why: why.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conclude(workspace: Workspace, text: &str) -> Action {
|
||||
Action::Conclude {
|
||||
workspace,
|
||||
peer: "kyle".to_string(),
|
||||
session: sid("chat-1"),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unconclude(workspace: Workspace, text: &str) -> Action {
|
||||
Action::Unconclude {
|
||||
workspace,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decided(item: &Item, action: Action) -> Record {
|
||||
Record::Decided {
|
||||
time: time(),
|
||||
item: item.clone(),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sent(item: &Item, ids: &[&str]) -> Record {
|
||||
Record::Sent {
|
||||
time: time(),
|
||||
item: item.clone(),
|
||||
ids: ids.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(item: &Item, status: Option<u16>) -> Record {
|
||||
Record::Failed {
|
||||
time: time(),
|
||||
item: item.clone(),
|
||||
status,
|
||||
kind: "refused".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `record`'s line, as `append` writes it.
|
||||
pub fn line(record: &Record) -> String {
|
||||
format!("{}\n", serde_json::to_string(record).unwrap())
|
||||
}
|
||||
Reference in New Issue
Block a user