bxctl: reindex, and memory log, undo, pending, accept, reject

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 16:41:44 -07:00
committed by kyle
parent 15ee4b8e3d
commit 0a1c206d50
6 changed files with 350 additions and 1 deletions
+13 -1
View File
@@ -28,7 +28,13 @@ usage: bxctl <command> [options]
Check that the grants load.
bxctl audit verify [--home DIR]
Check the audit log's hash chain, reading the files directly.";
Check the audit log's hash chain, reading the files directly.
bxctl reindex [--memory-socket PATH]
Rebuild memoryd's index from the session logs and notes.
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> [--memory-socket PATH]
List memory edits, undo the latest edit of a file, or answer writes waiting for review.";
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.
@@ -90,6 +96,10 @@ pub enum Command {
AuditVerify {
home: PathBuf,
},
Memory {
socket: PathBuf,
ask: crate::memory::Ask,
},
}
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
@@ -104,6 +114,8 @@ pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
"refuse" | "deny" => parse_refuse(rest, home),
"grants" => parse_grants(rest, home),
"audit" => parse_audit(rest, home),
"reindex" => crate::memory::parse_reindex(rest, home),
"memory" => crate::memory::parse_memory(rest, home),
// A flag before the command, or any unknown command, is not valid.
_ => Err(UsageError),
}
+1
View File
@@ -4,4 +4,5 @@ pub mod admin;
pub mod chat;
pub mod cli;
pub mod escape;
pub mod memory;
pub mod verify;
+10
View File
@@ -49,6 +49,16 @@ fn main() -> ExitCode {
reason,
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
Command::Memory { socket, ask } => {
let mut out = std::io::stdout().lock();
match bxctl::memory::run(&socket, &ask, bxctl::memory::MEMORY_TIMEOUT, &mut out) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
}
}
Command::AuditVerify { home } => {
let mut out = std::io::stdout().lock();
match bxctl::verify::run(&home, &mut out) {
+140
View File
@@ -0,0 +1,140 @@
//! The owner's memory commands over `memoryd`'s owner socket: `reindex`, and `memory log`,
//! `undo`, `pending`, `accept`, `reject` (M5a spec, section 10).
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use proto::{
Empty, Envelope, MemoryAdmin, MemoryCommand, Message, PROTOCOL_VERSION, read_frame, write_frame,
};
use crate::cli::{Command, UsageError};
use crate::escape::escape_json_text;
/// How long `bxctl` waits for `memoryd`: a reindex reads every session log.
pub const MEMORY_TIMEOUT: Duration = Duration::from_secs(300);
pub fn default_socket(home: &Path) -> PathBuf {
home.join("run").join("owner-memory").join("admin.sock")
}
/// What to ask `memoryd`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ask {
Reindex,
Admin(MemoryCommand, String),
}
/// `bxctl reindex [--memory-socket PATH]`.
pub fn parse_reindex(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (socket, rest) = socket_flag(args, home)?;
if !rest.is_empty() {
return Err(UsageError);
}
Ok(Command::Memory {
socket,
ask: Ask::Reindex,
})
}
/// `bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id>`, each with an optional
/// `--memory-socket PATH`.
pub fn parse_memory(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (socket, rest) = socket_flag(args, home)?;
let rest: Vec<&str> = rest.iter().map(String::as_str).collect();
let ask = match rest.as_slice() {
["log"] => Ask::Admin(MemoryCommand::Log, String::new()),
["log", n] => {
if n.parse::<usize>().is_err() {
return Err(UsageError);
}
Ask::Admin(MemoryCommand::Log, n.to_string())
}
["pending"] => Ask::Admin(MemoryCommand::Pending, String::new()),
["undo", id] => Ask::Admin(MemoryCommand::Undo, id.to_string()),
["accept", id] => Ask::Admin(MemoryCommand::Accept, id.to_string()),
["reject", id] => Ask::Admin(MemoryCommand::Reject, id.to_string()),
_ => return Err(UsageError),
};
Ok(Command::Memory { socket, ask })
}
/// Take `--memory-socket PATH` out of `args`, once at most; the rest in order.
fn socket_flag(args: &[String], home: &Path) -> Result<(PathBuf, Vec<String>), UsageError> {
let mut socket = default_socket(home);
let mut rest = Vec::with_capacity(args.len());
let mut seen = false;
let mut i = 0;
while i < args.len() {
if args[i] == "--memory-socket" {
if seen {
return Err(UsageError);
}
seen = true;
socket = PathBuf::from(args.get(i + 1).cloned().ok_or(UsageError)?);
i += 2;
} else {
rest.push(args[i].clone());
i += 1;
}
}
Ok((socket, rest))
}
/// `n` with commas between thousands.
fn grouped(n: u64) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// Ask `memoryd` and print its answer. `Err` is the line for stderr.
pub fn run(socket: &Path, ask: &Ask, timeout: Duration, out: &mut dyn Write) -> Result<(), String> {
let mut stream = UnixStream::connect(socket)
.map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?;
stream
.set_write_timeout(Some(timeout))
.map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?;
let msg = match ask {
Ask::Reindex => Message::Reindex(Empty {}),
Ask::Admin(command, arg) => Message::MemoryAdmin(MemoryAdmin {
command: *command,
arg: arg.clone(),
}),
};
let env = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
};
write_frame(&mut stream, &env)
.map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?;
let reply = read_frame(&mut stream).map_err(|e| format!("memoryd did not answer: {e}"))?;
let text = match reply.msg {
Message::Reindexed(r) => format!(
"reindexed: {} logs, {} records, {} notes",
grouped(r.logs),
grouped(r.records),
grouped(r.notes)
),
Message::MemoryAdminReply(reply) => reply.text,
Message::Error(e) => return Err(format!("memoryd: {}", escape_json_text(&e.detail))),
_ => return Err("memoryd answered with the wrong message".to_string()),
};
writeln!(out, "{text}").map_err(|e| e.to_string())
}