//! 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 proto::escape::escape_model_text; use proto::{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, 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 = 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, 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 = 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 { // 1. `args.check()?`. // 2. `day` is the first 10 characters of `ctx.now.to_rfc3339()`. `taken` = how many Waiting // lines have an id starting format!("p-{day}-"). 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[..10]; let taken = waiting(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 { // 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 = 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 { // 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 { // 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, 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, undoes: Some(id.to_string()), }, now, )?; Ok(format!("rejected: {id}")) }