//! 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, Vec) { (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 { let mut left: Vec<&String> = b.iter().collect(); let mut out: Vec = 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 { 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 { let mut current = record; for _ in 0..=records.len() { if current.session != OWNER { return SessionId::new(¤t.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 { let moved: Vec = 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 = 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 { 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 { let notes = notes_of(record); match &record.file { Some(file) => identity_actions(record, file, ¬es, session, agent, owner), None => note_actions(record, ¬es, session, agent), } } /// With `[honcho]`: decides each journalled edit into the ledger. pub struct Mirror { ledger: Arc>, agent: String, owner: String, } impl Mirror { pub fn new(ledger: Arc>, 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; } } } }