memoryd: memory writes decided into the Honcho ledger as they are journalled
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
//! 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::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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user