223 lines
7.6 KiB
Rust
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(¤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<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, ¬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<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;
|
|
}
|
|
}
|
|
}
|
|
}
|