//! 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::{DataClass, SessionId, Timestamp}; use crate::files::Store; use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace}; use crate::honcho::report; use crate::honcho::status::{self, Shared}; 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"; /// Why a write from a `secret` session sends nothing but its deletions (M5c spec, section 3). pub const SECRET_WRITE: &str = "the write is from a secret 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, peer: peer.to_string(), 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() } /// Whether `record` came from a `secret` session: by the class the journal gives the call, or, for /// a line from before it did, by the class any of its notes was written with. fn is_secret(record: &journal::Record, notes: &[notes::Note]) -> bool { record.class.as_deref() == Some(notes::class_name(DataClass::Secret)) || notes.iter().any(|n| { matches!( n.kind, Kind::Noted { class: DataClass::Secret, .. } ) }) } /// 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. From a `secret` session only the deletions /// go, in their order, and everything else the write asked is one `Skip` after them: deleting /// leaks nothing and keeps Honcho consistent with the files. pub fn actions( record: &journal::Record, session: Option<&SessionId>, agent: &str, owner: &str, ) -> Vec { let notes = notes_of(record); let all = match &record.file { Some(file) => identity_actions(record, file, ¬es, session, agent, owner), None => note_actions(record, ¬es, session, agent), }; if !is_secret(record, ¬es) { return all; } let (mut kept, held): (Vec, Vec) = all .into_iter() .partition(|a| matches!(a, Action::Unconclude { .. })); if !held.is_empty() { kept.push(Action::Skip { why: SECRET_WRITE.to_string(), }); } kept } /// Whether an undo's `action` is kept: an entry it brings back is concluded again only when its /// conclusion was made and then deleted (M5c spec, section 4: "any it had deleted, written /// again"). One never concluded (a secret session's, from before sync, skipped) or still /// concluded (moved out by `consolidate`) gets nothing. Every other action is kept. fn brought_back(ledger: &Ledger, action: &Action) -> bool { match action { Action::Conclude { workspace, peer, text, .. } => ledger.unconcluded(*workspace, peer, text), _ => true, } } /// With `[honcho]`: decides each journalled edit into the ledger. pub struct Mirror { ledger: Arc>, /// What the worker and the walk wait on, for the report; empty unless `with_status` is given. status: Shared, agent: String, owner: String, } impl Mirror { pub fn new(ledger: Arc>, agent: &str, owner: &str) -> Mirror { Mirror { ledger, status: Shared::default(), agent: agent.to_string(), owner: owner.to_string(), } } /// The same, reporting `status` too. pub fn with_status(self, status: Shared) -> Mirror { Mirror { status, ..self } } /// `bxctl memory sync`'s report: the status copied under its lock alone, then the ledger /// under its own. pub fn report(&self) -> String { let status = status::read(&self.status); report::with_status( &self.ledger.lock().unwrap_or_else(|p| p.into_inner()), &status, ) } /// 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; the ledger's lock is /// taken inside the index lock, and before an undo is decided against it. Its parts are /// `Edit { id, part }` from 0, numbering what is decided. An edit already decided, or asking /// nothing, adds no line. A failure is logged; the edit itself stands. Whether any line was /// added, so that the worker is woken only when there is something new. pub fn journalled( &self, store: &Store, id: Option<&str>, now: Timestamp, log: &dyn Fn(&str), ) -> bool { 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 false; } }; let found = match id { Some(id) => records.iter().find(|r| r.id == id), None => records.last(), }; let Some(record) = found else { return false; }; let session = session_of(record, &records); let mut book = self.ledger.lock().unwrap_or_else(|p| p.into_inner()); let mut actions = actions(record, session.as_ref(), &self.agent, &self.owner); if record.undoes.is_some() { actions.retain(|a| brought_back(&book, a)); } let mut added = false; for (part, action) in actions.into_iter().enumerate() { let Ok(part) = u32::try_from(part) else { return added; }; 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 added; } added = true; } added } }