Files
boxmaker/crates/memoryd/src/honcho/mirror.rs
T
2026-09-28 13:22:26 -07:00

223 lines
7.6 KiB
Rust

//! Memory writes mirrored in Honcho (M5c spec, section 4): each journalled edit is decided into
//! the ledger the moment it is journalled, one item per Honcho action, in the order they must be
//! sent. Only decided here; the worker sends (task 13). The text is kept as journalled.
use std::sync::{Arc, Mutex};
use bxcore::{SessionId, Timestamp};
use crate::files::Store;
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
use crate::honcho::report;
use crate::journal;
use crate::notes::{self, Kind};
/// The session the journal gives an owner's edit (`bxctl memory undo`).
pub const OWNER: &str = "owner";
/// The part of an edit that has no Boxmaker session to stamp its conclusion with.
pub const NO_SESSION: &str = "the edit has no Boxmaker session";
/// `before` and `after` as multisets of entry text: what `before` has more of (dropped, in
/// `before`'s order), and what `after` has more of (added, in `after`'s order).
pub fn diff(before: &[String], after: &[String]) -> (Vec<String>, Vec<String>) {
(minus(before, after), minus(after, before))
}
/// `a` without one occurrence of each text of `b`, in `a`'s order.
fn minus(a: &[String], b: &[String]) -> Vec<String> {
let mut left: Vec<&String> = b.iter().collect();
let mut out: Vec<String> = Vec::new();
for text in a {
match left.iter().position(|t| *t == text) {
Some(at) => {
left.swap_remove(at);
}
None => out.push(text.clone()),
}
}
out
}
/// The journalled notes of `record`, read back.
fn notes_of(record: &journal::Record) -> Vec<notes::Note> {
record.notes.iter().flat_map(|n| notes::parse(n)).collect()
}
/// The session an edit's conclusions carry: its own, or for an owner's undo the undone edit's,
/// following `undoes` back through undos of undos. `None` when there is none.
pub fn session_of(record: &journal::Record, records: &[journal::Record]) -> Option<SessionId> {
let mut current = record;
for _ in 0..=records.len() {
if current.session != OWNER {
return SessionId::new(&current.session).ok();
}
let undone = current.undoes.as_deref()?;
current = records.iter().find(|r| r.id == undone)?;
}
None
}
/// A conclusion about `peer` in the trusted workspace, stamped with `session`; without a session
/// it cannot be stamped, and is a `Skip`.
fn conclusion(peer: &str, session: Option<&SessionId>, text: &str) -> Action {
match session {
Some(session) => Action::Conclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
session: session.clone(),
text: text.to_string(),
},
None => Action::Skip {
why: NO_SESSION.to_string(),
},
}
}
/// An identity edit (an undo included), `file` its name: each entry dropped, a conclusion
/// deleted, then each entry added, a conclusion made, about the owner for `user.md` and the agent
/// for `memory.md`. Entries `consolidate` moved to notes are not dropped: they are still held.
fn identity_actions(
record: &journal::Record,
file: &str,
notes: &[notes::Note],
session: Option<&SessionId>,
agent: &str,
owner: &str,
) -> Vec<Action> {
let moved: Vec<String> = notes
.iter()
.filter(|n| matches!(n.kind, Kind::Moved(_)))
.map(|n| n.text.clone())
.collect();
let (dropped, added) = diff(&record.before, &record.after);
let peer = if file == "user.md" { owner } else { agent };
let mut out: Vec<Action> = minus(&dropped, &moved)
.into_iter()
.map(|text| Action::Unconclude {
workspace: Workspace::Trusted,
text,
})
.collect();
out.extend(added.iter().map(|text| conclusion(peer, session, text)));
out
}
/// A write saved as notes: when flagged, one `Skip` naming the rule; otherwise each `noted` note,
/// a fleet message when the write was untrusted, else a conclusion about the agent.
fn note_actions(
record: &journal::Record,
notes: &[notes::Note],
session: Option<&SessionId>,
agent: &str,
) -> Vec<Action> {
if let Some(rule) = &record.flag {
return vec![Action::Skip {
why: format!("flagged: {rule}"),
}];
}
notes
.iter()
.filter(|n| matches!(n.kind, Kind::Noted { .. }))
.map(|n| {
if record.untrusted {
match session {
Some(session) => Action::Fleet {
session: session.clone(),
text: n.text.clone(),
},
None => Action::Skip {
why: NO_SESSION.to_string(),
},
}
} else {
conclusion(agent, session, &n.text)
}
})
.collect()
}
/// What `record` asks of Honcho, in the order to send it: an identity edit's, or a write saved as
/// notes. Anything else (a rejection) asks nothing.
pub fn actions(
record: &journal::Record,
session: Option<&SessionId>,
agent: &str,
owner: &str,
) -> Vec<Action> {
let notes = notes_of(record);
match &record.file {
Some(file) => identity_actions(record, file, &notes, session, agent, owner),
None => note_actions(record, &notes, session, agent),
}
}
/// With `[honcho]`: decides each journalled edit into the ledger.
pub struct Mirror {
ledger: Arc<Mutex<Ledger>>,
agent: String,
owner: String,
}
impl Mirror {
pub fn new(ledger: Arc<Mutex<Ledger>>, agent: &str, owner: &str) -> Mirror {
Mirror {
ledger,
agent: agent.to_string(),
owner: owner.to_string(),
}
}
/// `bxctl memory sync`'s report of the ledger, under the ledger's lock alone.
pub fn report(&self) -> String {
report::report(&self.ledger.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Decide the journalled edit `id` (the latest when `None`), called under the index lock
/// right after it was journalled, so the ledger has the journal's order. Its parts are
/// `Edit { id, part }` from 0. An edit already decided, or asking nothing, adds no line. A
/// failure is logged; the edit itself stands.
pub fn journalled(&self, store: &Store, id: Option<&str>, now: Timestamp, log: &dyn Fn(&str)) {
let records = match journal::records(store) {
Ok(records) => records,
Err(e) => {
log(&format!(
"memoryd: {e}; the edit is not mirrored in Honcho; {}",
ledger::RUNBOOK
));
return;
}
};
let found = match id {
Some(id) => records.iter().find(|r| r.id == id),
None => records.last(),
};
let Some(record) = found else {
return;
};
let session = session_of(record, &records);
let actions = actions(record, session.as_ref(), &self.agent, &self.owner);
let mut book = self.ledger.lock().unwrap_or_else(|p| p.into_inner());
for (part, action) in actions.into_iter().enumerate() {
let Ok(part) = u32::try_from(part) else {
return;
};
let item = Item::Edit {
id: record.id.clone(),
part,
};
if book.is_decided(&item) {
continue;
}
if let Err(e) = book.append(Record::Decided {
time: now,
item,
action,
}) {
log(&format!("memoryd: {e}; {}", ledger::RUNBOOK));
return;
}
}
}
}