A shared status, kept by the worker and the walk and read by the report under its own leaf lock, holds the workspaces whose key was refused, when an outage of Honcho began, and when brokerd stopped answering. The report adds a line for each while it lasts, and is unchanged when all is well. The runbook's Honcho entries and broker-state-unavailable now say what sync shows. Tuning and Wake move to honcho/wake.rs (re-exported from worker) to keep worker.rs under 500 lines. honcho_sync.rs's serve test now allows the outage line: the write wakes the worker, which finds Honcho unreachable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
300 lines
10 KiB
Rust
300 lines
10 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::{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<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,
|
|
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<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()
|
|
}
|
|
|
|
/// 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<Action> {
|
|
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<Action>, Vec<Action>) = 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<Mutex<Ledger>>,
|
|
/// 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<Mutex<Ledger>>, 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
|
|
}
|
|
}
|