Task 01 changes bxcore's wire and log types for M5c-1: Turn.speaker and LogRecord::User.speaker (optional, skipped when None, so old frames and logs keep their bytes), SessionUpdated.turn (required, refused when missing), and the new SessionState / SessionStateReply messages. The other crates only construct the new fields (speaker: None, turn: 0) and brokerd's kind() names the two new kinds; later tasks give them values. 20 given files. Checks, against a reference at7b40686with its own build directory: - every "before" block found exactly once at7b40686(and at master, which changed no crate since); - step 2's failures as the task names them: two strict tests, fifteen test targets with E0559/E0560/E0432/E0599 only; - make gate: gate: ok, 1130 tests (1122 + 8); /tmp unchanged around it; - replay from the task text in a fresh worktree of7b40686with its own target/: crates/ byte-identical to the reference, gate ok, 1130; removed; - 23 mutations: 21 caught; the two that drop `default` from an Option field cannot change behaviour (serde reads a missing Option as None); - no chat-template tag whole in the task or the given files; lines over 100 characters only in tables, fixtures and verbatim strings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
366 lines
11 KiB
Rust
366 lines
11 KiB
Rust
//! `memoryd` serving its three sockets end to end: the block, `session_updated`, `recall` and
|
|
//! `memory` for `brokerd`, the owner's commands, a reindex, and messages on the wrong socket
|
|
//! refused (M5a spec, section 7); an entry that breaks a refuse rule is left out of the block and
|
|
//! logged with the rule's name (M5b spec, section 7). Do not edit.
|
|
|
|
#[path = "support/logs.rs"]
|
|
mod logs;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use bxcore::{
|
|
CallId, DataClass, Empty, Envelope, ErrorCode, MemoryAdmin, MemoryCall, MemoryCommand, Message,
|
|
SessionId, SessionUpdated, ToolRequest, ToolResponse, read_frame, write_frame,
|
|
};
|
|
use logs::{append, result, user, write_log};
|
|
use memoryd::config::Config;
|
|
use memoryd::serve;
|
|
use tmp::TempDir;
|
|
|
|
struct Running {
|
|
dir: TempDir,
|
|
config: Config,
|
|
lines: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
fn start(tag: &str, review: bool) -> Running {
|
|
let dir = TempDir::new(tag);
|
|
dir.write("etc/soul.md", "You are Boxmaker.\n");
|
|
write_log(dir.path(), "mm-old", &[user(1, "the old launch plan")]);
|
|
let text = format!(
|
|
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\nreview = {review}\n",
|
|
dir.path().display(),
|
|
dir.path().join("etc/soul.md").display()
|
|
);
|
|
let config = Config::parse(&text).unwrap();
|
|
let lines = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = Arc::clone(&lines);
|
|
let log: serve::Log = Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string()));
|
|
let running = serve::start(config.clone(), log).unwrap();
|
|
std::thread::spawn(move || running.run());
|
|
Running { dir, config, lines }
|
|
}
|
|
|
|
fn ask(socket: &Path, msg: Message) -> Message {
|
|
let mut stream = UnixStream::connect(socket).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
let env = Envelope {
|
|
v: 1,
|
|
id: 9,
|
|
r#final: true,
|
|
msg,
|
|
};
|
|
write_frame(&mut stream, &env).unwrap();
|
|
let reply = read_frame(&mut stream).unwrap();
|
|
assert_eq!((reply.id, reply.r#final), (9, true));
|
|
reply.msg
|
|
}
|
|
|
|
fn call(r: &Running, session: &str, tool: &str, arguments: &str, untrusted: bool) -> ToolResponse {
|
|
let msg = Message::MemoryCall(MemoryCall {
|
|
request: ToolRequest {
|
|
session: SessionId::new(session).unwrap(),
|
|
call: CallId(1),
|
|
tool: tool.to_string(),
|
|
arguments: arguments.to_string(),
|
|
},
|
|
taint: DataClass::Private,
|
|
untrusted,
|
|
});
|
|
match ask(&r.config.broker_socket(), msg) {
|
|
Message::ToolResponse(response) => response,
|
|
other => panic!("not a tool response: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn content(response: ToolResponse) -> (String, bool) {
|
|
match response {
|
|
ToolResponse::Result {
|
|
content, untrusted, ..
|
|
} => (content, untrusted),
|
|
other => panic!("not a result: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_block_and_a_write_that_reaches_the_next_block() {
|
|
let r = start("serve-block", false);
|
|
let Message::MemoryBlockReply(first) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(
|
|
first
|
|
.text
|
|
.starts_with("# soul.md\nYou are Boxmaker.\n\n# memory.md (0 of 2,200")
|
|
);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"user","text":"Prefers UTC."}"#,
|
|
false,
|
|
));
|
|
assert_eq!(
|
|
answer,
|
|
"user.md updated (12 of 1,375 characters); it takes effect in the next session"
|
|
);
|
|
let Message::MemoryBlockReply(next) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(
|
|
next.text
|
|
.ends_with("# user.md (12 of 1,375 characters)\nPrefers UTC."),
|
|
"{}",
|
|
next.text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_finds_old_sessions_updated_ones_and_notes() {
|
|
let r = start("serve-recall", false);
|
|
let (found, untrusted) = content(call(&r, "mm-a", "recall", r#"{"query":"launch"}"#, false));
|
|
assert!(
|
|
found.contains("the old launch plan"),
|
|
"caught up at start: {found}"
|
|
);
|
|
assert!(!untrusted);
|
|
let log = write_log(
|
|
r.dir.path(),
|
|
"mm-b",
|
|
&[result(1, "page", DataClass::Private, true)],
|
|
);
|
|
append(&log, &user(2, "the launch is Thursday"));
|
|
let update = Message::SessionUpdated(SessionUpdated {
|
|
session: SessionId::new("mm-b").unwrap(),
|
|
turn: 1,
|
|
});
|
|
assert_eq!(ask(&r.config.loop_socket(), update), Message::Ok(Empty {}));
|
|
call(
|
|
&r,
|
|
"mm-c",
|
|
"memory",
|
|
r#"{"action":"note","text":"example.org moved the launch"}"#,
|
|
true,
|
|
);
|
|
let (found, untrusted) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"recall",
|
|
r#"{"query":"launch","k":10}"#,
|
|
false,
|
|
));
|
|
assert!(found.contains("the launch is Thursday"), "{found}");
|
|
assert!(found.contains("memory-tainted · note,"), "{found}");
|
|
assert!(untrusted);
|
|
}
|
|
|
|
#[test]
|
|
fn an_untrusted_session_writes_notes_not_identity() {
|
|
let r = start("serve-untrusted", false);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Planted fact."}"#,
|
|
true,
|
|
));
|
|
assert_eq!(
|
|
answer,
|
|
"not added to memory.md: this session has read untrusted content, so it was saved in notes.md as an untrusted note"
|
|
);
|
|
let memory = std::fs::read_to_string(r.dir.path().join("memory/memory.md")).unwrap();
|
|
assert_eq!(memory, "");
|
|
let failed = call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"remove","file":"memory","old":"x"}"#,
|
|
true,
|
|
);
|
|
assert_eq!(
|
|
failed,
|
|
ToolResponse::Failed {
|
|
message: "this session has read untrusted content; it can take notes but not change memory.md".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_calls_fail_with_a_sentence() {
|
|
let r = start("serve-bad", false);
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "shell", "{}", false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "memory", r#"{"action":"forget"}"#, false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "recall", r#"{"query":""}"#, false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn review_holds_identity_writes_but_not_notes() {
|
|
let r = start("serve-review", true);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Held."}"#,
|
|
false,
|
|
));
|
|
assert!(
|
|
answer.starts_with("waiting for the owner's review (p-"),
|
|
"{answer}"
|
|
);
|
|
let (noted, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"note","text":"Not held."}"#,
|
|
false,
|
|
));
|
|
assert_eq!(noted, "saved in notes.md");
|
|
let (untrusted, _) = content(call(
|
|
&r,
|
|
"mm-b",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Not held either."}"#,
|
|
true,
|
|
));
|
|
assert_eq!(
|
|
untrusted,
|
|
"not added to memory.md: this session has read untrusted content, so it was saved in notes.md as an untrusted note"
|
|
);
|
|
let admin = |command, arg: &str| match ask(
|
|
&r.config.admin_socket(),
|
|
Message::MemoryAdmin(MemoryAdmin {
|
|
command,
|
|
arg: arg.to_string(),
|
|
}),
|
|
) {
|
|
Message::MemoryAdminReply(reply) => reply.text,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
let pending = admin(MemoryCommand::Pending, "");
|
|
let id = pending.split_whitespace().next().unwrap().to_string();
|
|
assert!(admin(MemoryCommand::Accept, &id).starts_with(&format!("accepted: {id} as ")));
|
|
assert_eq!(
|
|
std::fs::read_to_string(r.dir.path().join("memory/memory.md")).unwrap(),
|
|
"Held.\n"
|
|
);
|
|
let log = admin(MemoryCommand::Log, "");
|
|
assert_eq!(
|
|
log.lines().count(),
|
|
3,
|
|
"a note, a note from an untrusted add, the accepted add: {log}"
|
|
);
|
|
let edit = log
|
|
.lines()
|
|
.last()
|
|
.unwrap()
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(admin(MemoryCommand::Undo, &edit).starts_with("undone: "));
|
|
let refused = ask(
|
|
&r.config.admin_socket(),
|
|
Message::MemoryAdmin(MemoryAdmin {
|
|
command: MemoryCommand::Log,
|
|
arg: "ten".to_string(),
|
|
}),
|
|
);
|
|
assert!(
|
|
matches!(refused, Message::Error(ref e) if e.code == ErrorCode::Internal),
|
|
"{refused:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reindex_rebuilds_and_recall_still_answers() {
|
|
let r = start("serve-reindex", false);
|
|
write_log(
|
|
r.dir.path(),
|
|
"mm-new",
|
|
&[user(1, "written while nobody told memoryd")],
|
|
);
|
|
let Message::Reindexed(counts) = ask(&r.config.admin_socket(), Message::Reindex(Empty {}))
|
|
else {
|
|
panic!("not reindexed")
|
|
};
|
|
assert_eq!((counts.logs, counts.records, counts.notes), (2, 2, 0));
|
|
let (found, _) = content(call(&r, "mm-a", "recall", r#"{"query":"launch"}"#, false));
|
|
assert!(found.contains("the old launch plan"));
|
|
let (found, _) = content(call(&r, "mm-a", "recall", r#"{"query":"nobody"}"#, false));
|
|
assert!(
|
|
found.contains("written while nobody told memoryd"),
|
|
"the new index answers: {found}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_on_the_wrong_socket_is_refused() {
|
|
let r = start("serve-wrong", false);
|
|
let reply = ask(&r.config.broker_socket(), Message::MemoryBlock(Empty {}));
|
|
assert!(
|
|
matches!(reply, Message::Error(ref e) if e.code == ErrorCode::BadMessage),
|
|
"{reply:?}"
|
|
);
|
|
let reply = ask(&r.config.loop_socket(), Message::Reindex(Empty {}));
|
|
assert!(
|
|
matches!(reply, Message::Error(ref e) if e.code == ErrorCode::BadMessage),
|
|
"{reply:?}"
|
|
);
|
|
}
|
|
|
|
/// A GitHub token, built from pieces so that no file holds one whole: the prefix, then 36 letters
|
|
/// and digits.
|
|
fn github_key() -> String {
|
|
let body: String = "Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0Uv"
|
|
.chars()
|
|
.cycle()
|
|
.take(36)
|
|
.collect();
|
|
["gh", "p_", &body].concat()
|
|
}
|
|
|
|
#[test]
|
|
fn an_entry_that_fails_the_check_is_left_out_and_logged() {
|
|
let r = start("serve-leftout", false);
|
|
std::fs::write(
|
|
r.dir.path().join("memory/memory.md"),
|
|
format!("Kept.\n§\nCI token {}, planted\n", github_key()),
|
|
)
|
|
.unwrap();
|
|
let Message::MemoryBlockReply(block) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(block.text.contains("\nKept.\n"), "{}", block.text);
|
|
assert!(!block.text.contains("planted"));
|
|
let lines = r.lines.lock().unwrap().clone();
|
|
assert!(
|
|
lines.contains(
|
|
&"memoryd: an entry of memory.md was left out of the block by the rule secret-github; see docs/runbook.md#memory-entry-excluded"
|
|
.to_string()
|
|
),
|
|
"{lines:?}"
|
|
);
|
|
}
|