Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving), the memory tools and built-in grants in brokerd, the memory block in loopd, bxctl reindex and memory, and the device checks with memoryd. Each task was built as a reference and gated. Each was replayed on its own at its end state (854 to 936 tests) and mutation-checked. Each skeleton compiles and fails its tests. Every todo comment was read beside its signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
//! 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> {
|
||||
// `socket_flag(args, home)?`; any argument left -> Err(UsageError). Ok(Command::Memory {
|
||||
// socket, ask: Ask::Reindex }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
// `socket_flag(args, home)?`, then match what is left: ["log"] -> Admin(Log, ""); ["log", n]
|
||||
// where n parses as usize -> Admin(Log, n); ["pending"] -> Admin(Pending, ""); ["undo", id] ->
|
||||
// Admin(Undo, id); ["accept", id] -> Admin(Accept, id); ["reject", id] -> Admin(Reject, id);
|
||||
// anything else -> Err(UsageError). Ok(Command::Memory { socket, ask }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Walk `args`: "--memory-socket" takes the next argument as the socket (none ->
|
||||
// Err(UsageError); a second --memory-socket -> Err(UsageError)); every other argument is kept
|
||||
// in order. The socket defaults to `default_socket(home)`. Ok((socket, rest)).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `n` with commas between thousands.
|
||||
fn grouped(n: u64) -> String {
|
||||
// As memoryd's `grouped`: the digits of n with a comma before each char at index i > 0 where
|
||||
// `(digits.len() - i).is_multiple_of(3)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// 1. Connect to `socket` and set read and write timeouts of `timeout`; each error ->
|
||||
// format!("cannot reach memoryd at {}: {e}", socket.display()).
|
||||
// 2. The message: Reindex -> Message::Reindex(Empty {}); Admin(command, arg) ->
|
||||
// Message::MemoryAdmin(MemoryAdmin { command, arg }). `write_frame` it in an Envelope { v:
|
||||
// PROTOCOL_VERSION, id: 1, final: true } (an error: the same "cannot reach" sentence).
|
||||
// 3. `read_frame` (an error -> format!("memoryd did not answer: {e}")). Reindexed(r) ->
|
||||
// format!("reindexed: {} logs, {} records, {} notes") with each count `grouped`;
|
||||
// MemoryAdminReply(reply) -> reply.text; Error(e) -> Err(format!("memoryd: {}",
|
||||
// escape_json_text(&e.detail))); anything else -> Err("memoryd answered with the wrong
|
||||
// message").
|
||||
// 4. `writeln!(out, "{text}")`, its error as a String.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! `bxctl reindex` and `bxctl memory`: parsing, what is sent to `memoryd`, and what is printed
|
||||
//! (M5a spec, section 10). Do not edit.
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxctl::cli::{Command, UsageError, parse};
|
||||
use bxctl::memory::{Ask, run};
|
||||
use proto::{
|
||||
Envelope, ErrorCode, MemoryAdminReply, MemoryCommand, Message, Reindexed, WireError,
|
||||
read_frame, write_frame,
|
||||
};
|
||||
|
||||
fn args(line: &str) -> Vec<String> {
|
||||
line.split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
PathBuf::from("/srv/bx")
|
||||
}
|
||||
|
||||
fn memory(ask: Ask) -> Result<Command, UsageError> {
|
||||
Ok(Command::Memory {
|
||||
socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"),
|
||||
ask,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_parse() {
|
||||
let h = home();
|
||||
assert_eq!(parse(&args("reindex"), &h), memory(Ask::Reindex));
|
||||
assert_eq!(
|
||||
parse(&args("memory log"), &h),
|
||||
memory(Ask::Admin(MemoryCommand::Log, String::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory log 3"), &h),
|
||||
memory(Ask::Admin(MemoryCommand::Log, "3".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory undo 2026-09-24-0007"), &h),
|
||||
memory(Ask::Admin(
|
||||
MemoryCommand::Undo,
|
||||
"2026-09-24-0007".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory pending"), &h),
|
||||
memory(Ask::Admin(MemoryCommand::Pending, String::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory accept p-1"), &h),
|
||||
memory(Ask::Admin(MemoryCommand::Accept, "p-1".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory reject p-1"), &h),
|
||||
memory(Ask::Admin(MemoryCommand::Reject, "p-1".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("reindex --memory-socket /r/m.sock"), &h),
|
||||
Ok(Command::Memory {
|
||||
socket: PathBuf::from("/r/m.sock"),
|
||||
ask: Ask::Reindex
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory --memory-socket /r/m.sock undo x"), &h),
|
||||
Ok(Command::Memory {
|
||||
socket: PathBuf::from("/r/m.sock"),
|
||||
ask: Ask::Admin(MemoryCommand::Undo, "x".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_lines_are_usage_errors() {
|
||||
for line in [
|
||||
"memory",
|
||||
"memory log ten",
|
||||
"memory log 1 2",
|
||||
"memory undo",
|
||||
"memory undo a b",
|
||||
"memory forget x",
|
||||
"memory pending x",
|
||||
"reindex now",
|
||||
"reindex --memory-socket",
|
||||
"reindex --memory-socket /a --memory-socket /b",
|
||||
] {
|
||||
assert_eq!(parse(&args(line), &home()), Err(UsageError), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake `memoryd` answering one request with `answer`; hands back what it was sent.
|
||||
fn fake(socket: &Path, answer: Message) -> mpsc::Receiver<Message> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = read_frame(&mut stream).unwrap();
|
||||
tx.send(env.msg).unwrap();
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: answer,
|
||||
};
|
||||
write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("bx-mem-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_prints_the_counts() {
|
||||
let d = dir("reindex");
|
||||
let socket = d.join("admin.sock");
|
||||
let seen = fake(
|
||||
&socket,
|
||||
Message::Reindexed(Reindexed {
|
||||
logs: 42,
|
||||
records: 1208,
|
||||
notes: 7,
|
||||
}),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
run(&socket, &Ask::Reindex, Duration::from_secs(5), &mut out).unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(out).unwrap(),
|
||||
"reindexed: 42 logs, 1,208 records, 7 notes\n"
|
||||
);
|
||||
assert!(matches!(seen.recv().unwrap(), Message::Reindex(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_admin_command_prints_the_reply_and_an_error_is_escaped() {
|
||||
let d = dir("admin");
|
||||
let socket = d.join("admin.sock");
|
||||
let seen = fake(
|
||||
&socket,
|
||||
Message::MemoryAdminReply(MemoryAdminReply {
|
||||
text: "undone: 2026-09-24-0007".to_string(),
|
||||
}),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let ask = Ask::Admin(MemoryCommand::Undo, "2026-09-24-0007".to_string());
|
||||
run(&socket, &ask, Duration::from_secs(5), &mut out).unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "undone: 2026-09-24-0007\n");
|
||||
match seen.recv().unwrap() {
|
||||
Message::MemoryAdmin(m) => {
|
||||
assert_eq!(
|
||||
(m.command, m.arg.as_str()),
|
||||
(MemoryCommand::Undo, "2026-09-24-0007")
|
||||
)
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let socket = d.join("refuses.sock");
|
||||
let _seen = fake(
|
||||
&socket,
|
||||
Message::Error(WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail: "user.md has changed\u{1b}[31m".to_string(),
|
||||
}),
|
||||
);
|
||||
let err = run(&socket, &ask, Duration::from_secs(5), &mut Vec::new()).unwrap_err();
|
||||
assert!(err.starts_with("memoryd: user.md has changed"), "{err}");
|
||||
assert!(!err.contains('\u{1b}'), "escaped: {err}");
|
||||
let err = run(
|
||||
&d.join("nobody.sock"),
|
||||
&ask,
|
||||
Duration::from_secs(1),
|
||||
&mut Vec::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("cannot reach memoryd at "), "{err}");
|
||||
}
|
||||
Reference in New Issue
Block a user