Files
boxmaker/crates/memoryd/src/review.rs
T
2026-09-27 19:08:42 -07:00

254 lines
8.8 KiB
Rust

//! Review (off by default, `[memory] review = true`): identity writes wait in
//! `memory/pending.jsonl` for the owner's `accept` or `reject` instead of being applied (M5a spec,
//! section 5). Notes are never staged.
use serde::{Deserialize, Serialize};
use bxcore::escape::escape_model_text;
use bxcore::{DataClass, MemoryArgs, Timestamp};
use crate::edit::{Context, apply};
use crate::files::Store;
use crate::journal::{self, Record};
/// One line of `pending.jsonl`: a write waiting, or what became of one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Line {
Waiting {
id: String,
time: String,
session: String,
call: u64,
taint: DataClass,
/// The call's arguments, as `brokerd` sent them.
arguments: String,
},
Accepted {
id: String,
time: String,
edit: String,
},
Rejected {
id: String,
time: String,
},
}
fn lines(store: &Store) -> Result<Vec<Line>, String> {
// Read `store.path("pending.jsonl")`: NotFound -> Ok(empty); another error -> Err(format!("{}:
// {e}", path.display())). Parse every line as a Line with serde_json; a bad one ->
// Err(format!("{}: line {}: {e}", path.display(), n + 1)).
let path = store.path("pending.jsonl");
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
let mut out: Vec<Line> = Vec::new();
for (n, line) in text.lines().enumerate() {
let entry: Line = serde_json::from_str(line)
.map_err(|e| format!("{}: line {}: {e}", path.display(), n + 1))?;
out.push(entry);
}
Ok(out)
}
fn add(store: &Store, line: &Line) -> Result<(), String> {
// `serde_json::to_string(line)` (its error as a String), then `store.append("pending.jsonl",
// &format!("{text}\n"))`.
let text = serde_json::to_string(line).map_err(|e| e.to_string())?;
store.append("pending.jsonl", &format!("{text}\n"))
}
/// The writes still waiting: `(id, session, arguments, taint, call)`.
fn waiting(store: &Store) -> Result<Vec<Line>, String> {
// The ids that have an Accepted or a Rejected line are done. Return a clone of every Waiting
// line whose id is not done, in file order.
let all = lines(store)?;
let done: Vec<String> = all
.iter()
.filter_map(|line| match line {
Line::Accepted { id, .. } | Line::Rejected { id, .. } => Some(id.clone()),
Line::Waiting { .. } => None,
})
.collect();
Ok(all
.iter()
.filter(|line| match line {
Line::Waiting { id, .. } => !done.iter().any(|d| d == id),
Line::Accepted { .. } | Line::Rejected { .. } => false,
})
.cloned()
.collect())
}
/// Stage an identity write. Its shape is checked now; its content when it is accepted.
pub fn submit(
store: &Store,
args: &MemoryArgs,
arguments: &str,
ctx: &Context,
) -> Result<String, String> {
// 1. `args.check()?`.
// 2. `day` is the first 10 characters of `ctx.now.to_rfc3339()`. `taken` = how many Waiting
// lines of `lines(store)?`, done or not, have an id starting format!("p-{day}-"): an id is
// never used twice. The id is format!("p-{day}-{:04}", taken + 1).
// 3. `add` a Waiting line: id, time `ctx.now.to_rfc3339()`, session, call and taint from ctx,
// arguments as given.
// 4. Ok(format!("waiting for the owner's review ({id}); nothing is changed until it is
// accepted")).
args.check()?;
let rfc = ctx.now.to_rfc3339();
let day = rfc.get(..10).unwrap_or("0000-00-00");
let taken = lines(store)?
.iter()
.filter(|line| match line {
Line::Waiting { id, .. } => id.starts_with(&format!("p-{day}-")),
_ => false,
})
.count();
let id = format!("p-{day}-{:04}", taken + 1);
add(
store,
&Line::Waiting {
id: id.clone(),
time: rfc,
session: ctx.session.clone(),
call: ctx.call,
taint: ctx.taint,
arguments: arguments.to_string(),
},
)?;
Ok(format!(
"waiting for the owner's review ({id}); nothing is changed until it is accepted"
))
}
/// The writes waiting, one per line, arguments escaped.
pub fn list(store: &Store) -> Result<String, String> {
// One line per `waiting` entry: format!("{id} {session} {}", escape_model_text(arguments)),
// joined by newlines; with none, "nothing is waiting for review".
let waits = waiting(store)?;
let lines: Vec<String> = waits
.iter()
.filter_map(|l| match l {
Line::Waiting {
id,
session,
arguments,
..
} => Some(format!("{id} {session} {}", escape_model_text(arguments))),
Line::Accepted { .. } | Line::Rejected { .. } => None,
})
.collect();
if lines.is_empty() {
Ok("nothing is waiting for review".to_string())
} else {
Ok(lines.join("\n"))
}
}
/// Apply a waiting write, with the same checks, against the file as it is now, and journal it.
pub fn accept(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
// 1. The Waiting line with this id among `waiting(store)?`, else Err(format!("nothing waiting
// as {id}")).
// 2. Parse its arguments as MemoryArgs (the error as a String).
// 3. A Context from its session, call and taint, untrusted false, now.
// 4. `apply(store, &args, &ctx)?`, then `journal::record(store, &ctx, &outcome)?` gives the
// edit id.
// 5. `add` an Accepted line { id, time now, edit }.
// 6. Ok(format!("accepted: {id} as {edit}: {}", outcome.answer)).
let waiting_lines = waiting(store)?;
let found = waiting_lines
.iter()
.find(|l| match l {
Line::Waiting { id: l_id, .. } => l_id == id,
Line::Accepted { .. } | Line::Rejected { .. } => false,
})
.ok_or_else(|| format!("nothing waiting as {id}"))?;
let (args, session, call, taint) = match found {
Line::Waiting {
arguments,
session,
call,
taint,
..
} => {
let args: MemoryArgs = serde_json::from_str(arguments).map_err(|e| e.to_string())?;
(args, session.clone(), *call, *taint)
}
Line::Accepted { .. } | Line::Rejected { .. } => {
return Err(format!("nothing waiting as {id}"));
}
};
let ctx = Context {
session,
call,
taint,
untrusted: false,
now,
};
let outcome = apply(store, &args, &ctx)?;
let edit = journal::record(store, &ctx, &outcome)?;
add(
store,
&Line::Accepted {
id: id.to_string(),
time: now.to_rfc3339(),
edit: edit.clone(),
},
)?;
Ok(format!("accepted: {id} as {edit}: {}", outcome.answer))
}
/// Discard a waiting write; the journal records it.
pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
// 1. The Waiting line with this id, else Err(format!("nothing waiting as {id}")).
// 2. `add` a Rejected line { id, time now }.
// 3. `journal::append` a Record: its session and call, action "reject", file None, to empty, no
// entries, no notes, untrusted false, flag None, undoes Some(id).
// 4. Ok(format!("rejected: {id}")).
let waiting_lines = waiting(store)?;
let found = waiting_lines
.iter()
.find(|l| match l {
Line::Waiting { id: l_id, .. } => l_id == id,
Line::Accepted { .. } | Line::Rejected { .. } => false,
})
.ok_or_else(|| format!("nothing waiting as {id}"))?;
let (session, call) = match found {
Line::Waiting { session, call, .. } => (session.clone(), *call),
Line::Accepted { .. } | Line::Rejected { .. } => {
return Err(format!("nothing waiting as {id}"));
}
};
add(
store,
&Line::Rejected {
id: id.to_string(),
time: now.to_rfc3339(),
},
)?;
journal::append(
store,
Record {
id: String::new(),
time: now.to_rfc3339(),
session,
call,
action: "reject".to_string(),
file: None,
to: String::new(),
before: Vec::new(),
after: Vec::new(),
notes: Vec::new(),
untrusted: false,
flag: None,
undoes: Some(id.to_string()),
},
now,
)?;
Ok(format!("rejected: {id}"))
}