use std::{
path::{Path, PathBuf},
process::{Child, Command},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use crate::{
ContentBlock, Message,
agent::{AgentConfig, AgentStatus},
memory::MemoryStore,
memory::journal::{AgentMemoryState, RunMemoryState},
provider::ProviderId,
runtime::{
AgentStore, LeaseStore, PermissionRuleContext, PermissionRuleStore, RunStore, RuntimeError,
TaskStore,
store::{PersistedAgentRecord, now_nanos},
},
session::{
PermissionRuleAddress, PermissionRuleScope,
permission::{RememberedRule, RuleKey},
},
transcript::{AgentTranscript, TranscriptItem},
};
use super::FileRuntimeStore;
fn temp_root(label: &str) -> PathBuf {
std::env::temp_dir()
.join("mentra-file-store-tests")
.join(format!("{label}-{}-{}", std::process::id(), now_nanos()))
}
fn agent_record(id: &str) -> PersistedAgentRecord {
PersistedAgentRecord {
id: id.to_string(),
runtime_identifier: "test-runtime".to_string(),
name: format!("agent-{id}"),
model: "test-model".to_string(),
provider_id: ProviderId::new("test"),
config: AgentConfig::default(),
hidden_tools: Default::default(),
max_rounds: Some(7),
teammate_identity: None,
rounds_since_task: 2,
idle_requested: false,
status: AgentStatus::default(),
subagents: Vec::new(),
}
}
fn user_entry(text: &str) -> TranscriptItem {
TranscriptItem::user_turn(Message::user(ContentBlock::text(text)))
}
fn transcript_of(texts: &[&str]) -> AgentTranscript {
let mut transcript = AgentTranscript::default();
for text in texts {
transcript.push(user_entry(text));
}
transcript
}
fn memory_with(transcript: AgentTranscript) -> AgentMemoryState {
AgentMemoryState {
transcript,
revision: 3,
resumable_user_message: Some(Message::user(ContentBlock::text("resume me"))),
..AgentMemoryState::default()
}
}
fn state_value(state: &AgentMemoryState) -> serde_json::Value {
serde_json::to_value(state).expect("serialize state")
}
fn record_value(record: &PersistedAgentRecord) -> serde_json::Value {
serde_json::to_value(record).expect("serialize record")
}
fn transcript_line_count(store: &FileRuntimeStore, agent_id: &str) -> usize {
let path = store.agent_dir(agent_id).join("transcript.jsonl");
std::fs::read_to_string(path)
.expect("read transcript log")
.lines()
.filter(|line| !line.is_empty())
.count()
}
#[test]
fn create_agent_then_load_round_trips_exactly() {
let store = FileRuntimeStore::new(temp_root("round-trip"));
let record = agent_record("agent-1");
let memory = memory_with(transcript_of(&["hello", "world"]));
store.create_agent(&record, &memory).expect("create agent");
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(record_value(&loaded.record), record_value(&record));
assert_eq!(state_value(&loaded.memory), state_value(&memory));
assert!(
loaded.created_at.is_some(),
"a durable store reports when it first wrote"
);
assert!(loaded.updated_at.is_some());
}
#[test]
fn a_branched_transcript_round_trips_with_its_archive_and_leaf() {
let store = FileRuntimeStore::new(temp_root("branch"));
let record = agent_record("agent-1");
let mut transcript = transcript_of(&["0", "1", "2"]);
let first = transcript.items()[0].id.clone();
let abandoned_leaf = transcript.leaf().expect("a leaf").clone();
store
.create_agent(&record, &memory_with(transcript.clone()))
.expect("create agent");
transcript.branch_from(&first).expect("branch");
transcript.push(user_entry("elsewhere"));
let memory = memory_with(transcript.clone());
store
.save_agent_memory("agent-1", &memory)
.expect("save branched state");
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(loaded.memory.transcript, transcript);
assert_eq!(loaded.memory.transcript.archived().len(), 2);
let mut reloaded = loaded.memory.transcript;
reloaded
.branch_from(&abandoned_leaf)
.expect("return to the abandoned branch");
assert_eq!(reloaded.items().len(), 3);
let leaf = std::fs::read_to_string(store.agent_dir("agent-1").join("leaf")).expect("read leaf");
assert_eq!(leaf, format!("{}\n", transcript.leaf().expect("a leaf")));
}
#[test]
fn a_run_baseline_round_trips_and_rolls_back_identically() {
let store = FileRuntimeStore::new(temp_root("baseline"));
let record = agent_record("agent-1");
let baseline = transcript_of(&["before"]);
let mut transcript = baseline.clone();
transcript.push(user_entry("during the run"));
let memory = AgentMemoryState {
transcript,
run: Some(RunMemoryState {
run_id: "run-1".to_string(),
baseline_transcript: baseline.clone(),
assistant_committed: false,
}),
revision: 9,
..AgentMemoryState::default()
};
store.create_agent(&record, &memory).expect("create agent");
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(state_value(&loaded.memory), state_value(&memory));
assert_eq!(
loaded
.memory
.run
.as_ref()
.expect("run state survives")
.baseline_transcript,
baseline,
"an interrupted run recovered from disk rolls back to the same baseline"
);
}
#[test]
fn a_replaced_transcript_loads_as_replaced_while_the_log_keeps_history() {
let store = FileRuntimeStore::new(temp_root("compaction"));
let record = agent_record("agent-1");
let original = memory_with(transcript_of(&["a", "b", "c"]));
store
.create_agent(&record, &original)
.expect("create agent");
let replacement = memory_with(transcript_of(&["summary of a-c", "d"]));
store
.save_agent_memory("agent-1", &replacement)
.expect("save replacement");
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(state_value(&loaded.memory), state_value(&replacement));
assert_eq!(transcript_line_count(&store, "agent-1"), 5);
}
#[test]
fn appends_do_not_duplicate_entries_across_reopen() {
let root = temp_root("reopen");
let mut transcript = transcript_of(&["one", "two"]);
{
let store = FileRuntimeStore::new(&root);
store
.create_agent(&agent_record("agent-1"), &memory_with(transcript.clone()))
.expect("create agent");
}
let store = FileRuntimeStore::new(&root);
transcript.push(user_entry("three"));
store
.save_agent_memory("agent-1", &memory_with(transcript.clone()))
.expect("save third entry");
assert_eq!(
transcript_line_count(&store, "agent-1"),
3,
"already-logged entries must not be appended again"
);
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(loaded.memory.transcript, transcript);
}
#[test]
fn a_truncated_final_line_is_skipped_and_the_next_append_gets_a_fresh_line() {
let root = temp_root("truncated");
let mut transcript = transcript_of(&["one", "two"]);
let store = FileRuntimeStore::new(&root);
store
.create_agent(&agent_record("agent-1"), &memory_with(transcript.clone()))
.expect("create agent");
let log_path = store.agent_dir("agent-1").join("transcript.jsonl");
let mut contents = std::fs::read(&log_path).expect("read log");
contents.extend_from_slice(br#"{"schema":1,"id":"entry-trunc"#);
std::fs::write(&log_path, contents).expect("write truncated log");
let reopened = FileRuntimeStore::new(&root);
let loaded = reopened
.load_agent("agent-1")
.expect("load agent despite the truncated tail")
.expect("agent present");
assert_eq!(loaded.memory.transcript, transcript);
transcript.push(user_entry("three"));
reopened
.save_agent_memory("agent-1", &memory_with(transcript.clone()))
.expect("append after damage");
let reloaded = reopened
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(reloaded.memory.transcript, transcript);
assert_eq!(transcript_line_count(&reopened, "agent-1"), 3);
for line in std::fs::read_to_string(&log_path)
.expect("read log")
.lines()
.filter(|line| !line.is_empty())
{
serde_json::from_str::<serde_json::Value>(line).expect("every kept line parses");
}
}
#[test]
fn leftover_temp_files_and_stray_entries_are_ignored() {
let store = FileRuntimeStore::new(temp_root("strays"));
let record = agent_record("agent-1");
store
.create_agent(&record, &memory_with(transcript_of(&["hello"])))
.expect("create agent");
let agent_dir = store.agent_dir("agent-1");
std::fs::write(agent_dir.join(".agent.json.tmp-999-1"), b"{ partial").expect("plant temp file");
std::fs::write(store.agents_dir().join("not-a-directory"), b"stray").expect("plant stray file");
std::fs::create_dir_all(store.agents_dir().join("half-created"))
.expect("plant record-less directory");
let agents = store.list_agents().expect("list agents");
assert_eq!(agents.len(), 1, "only the real agent is listed");
assert_eq!(agents[0].record.id, "agent-1");
let loaded = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(record_value(&loaded.record), record_value(&record));
}
#[test]
fn save_agent_record_without_memory_errors_on_load() {
let store = FileRuntimeStore::new(temp_root("no-memory"));
store
.save_agent_record(&agent_record("agent-1"))
.expect("save record");
let error = store
.load_agent("agent-1")
.expect_err("memory is missing until it is saved");
assert!(matches!(error, RuntimeError::Store(_)));
}
#[test]
fn resaving_a_record_moves_updated_at_and_keeps_created_at() {
let store = FileRuntimeStore::new(temp_root("timestamps"));
let mut record = agent_record("agent-1");
store
.create_agent(&record, &AgentMemoryState::default())
.expect("create agent");
let first = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
record.name = "renamed".to_string();
store
.save_agent_record(&record)
.expect("save renamed record");
let second = store
.load_agent("agent-1")
.expect("load agent")
.expect("agent present");
assert_eq!(second.record.name, "renamed");
assert_eq!(
second.created_at, first.created_at,
"the first write settles created_at for good"
);
assert!(second.updated_at >= first.updated_at);
}
#[test]
fn delete_agent_removes_everything_and_deleting_absent_succeeds() {
let store = FileRuntimeStore::new(temp_root("delete"));
store
.create_agent(
&agent_record("agent-1"),
&memory_with(transcript_of(&["x"])),
)
.expect("create agent");
store.delete_agent("agent-1").expect("delete agent");
assert!(store.load_agent("agent-1").expect("load agent").is_none());
assert!(store.list_agents().expect("list agents").is_empty());
assert!(!store.agent_dir("agent-1").exists());
store
.delete_agent("agent-1")
.expect("deleting an absent agent succeeds: the goal is that it be gone");
}
#[test]
fn a_crashed_delete_leaves_the_shape_readers_already_ignore() {
let store = FileRuntimeStore::new(temp_root("partial-delete"));
let memory = AgentMemoryState::default();
store
.create_agent(
&agent_record("agent-1"),
&memory_with(transcript_of(&["x"])),
)
.expect("create agent-1");
store
.create_agent(&agent_record("agent-2"), &memory)
.expect("create agent-2");
std::fs::remove_file(store.agent_dir("agent-1").join("agent.json"))
.expect("simulate the crash point");
assert!(
store
.load_agent("agent-1")
.expect("load half-deleted agent")
.is_none(),
"a record-less directory is not an agent"
);
let ids: Vec<_> = store
.list_agents()
.expect("listing survives a half-deleted agent")
.into_iter()
.map(|loaded| loaded.record.id)
.collect();
assert_eq!(ids, vec!["agent-2".to_string()]);
store.delete_agent("agent-1").expect("finish the delete");
assert!(!store.agent_dir("agent-1").exists());
}
#[test]
fn list_agents_orders_by_creation_and_filters_by_runtime() {
let store = FileRuntimeStore::new(temp_root("list"));
let memory = AgentMemoryState::default();
let mut second = agent_record("agent-b");
second.runtime_identifier = "other-runtime".to_string();
store
.create_agent(&agent_record("agent-a"), &memory)
.expect("create first");
store.create_agent(&second, &memory).expect("create second");
let ids: Vec<_> = store
.list_agents()
.expect("list agents")
.into_iter()
.map(|loaded| loaded.record.id)
.collect();
assert_eq!(ids, vec!["agent-a".to_string(), "agent-b".to_string()]);
let by_runtime: Vec<_> = store
.list_agents_by_runtime("other-runtime")
.expect("list by runtime")
.into_iter()
.map(|loaded| loaded.record.id)
.collect();
assert_eq!(by_runtime, vec!["agent-b".to_string()]);
}
#[test]
fn an_id_needing_encoding_still_round_trips() {
let store = FileRuntimeStore::new(temp_root("encoding"));
let record = agent_record("agent/one two");
store
.create_agent(&record, &AgentMemoryState::default())
.expect("create agent");
let loaded = store
.load_agent("agent/one two")
.expect("load agent")
.expect("agent present");
assert_eq!(loaded.record.id, "agent/one two");
assert_eq!(store.list_agents().expect("list agents").len(), 1);
}
#[test]
fn ids_differing_only_by_case_get_distinct_directories() {
let store = FileRuntimeStore::new(temp_root("case"));
assert_ne!(store.agent_dir("Agent"), store.agent_dir("agent"));
store
.create_agent(&agent_record("Agent"), &AgentMemoryState::default())
.expect("create Agent");
store
.create_agent(&agent_record("agent"), &AgentMemoryState::default())
.expect("create agent");
assert_eq!(
store
.load_agent("Agent")
.expect("load Agent")
.expect("Agent present")
.record
.id,
"Agent"
);
assert_eq!(
store
.load_agent("agent")
.expect("load agent")
.expect("agent present")
.record
.id,
"agent"
);
assert_eq!(store.list_agents().expect("list agents").len(), 2);
}
#[test]
fn hazardous_names_route_to_the_encoded_form() {
let store = FileRuntimeStore::new(temp_root("hazard"));
for id in [
"con", "con.log", "com1", "lpt9", "foo.", "x-6162", "Agent", ".hidden",
] {
let dir_name = store
.agent_dir(id)
.file_name()
.and_then(|name| name.to_str())
.expect("dir name")
.to_string();
assert!(
dir_name.starts_with("x-"),
"'{id}' must be encoded, got '{dir_name}'"
);
}
for id in ["agent-1", "com0", "lpt0", "foo.log", "conx"] {
let dir_name = store
.agent_dir(id)
.file_name()
.and_then(|name| name.to_str())
.expect("dir name")
.to_string();
assert_eq!(dir_name, id, "'{id}' needs no encoding");
}
}
#[test]
fn a_file_from_a_newer_schema_is_refused_not_misread() {
let store = FileRuntimeStore::new(temp_root("schema"));
store
.create_agent(&agent_record("agent-1"), &AgentMemoryState::default())
.expect("create agent");
let path = store.agent_dir("agent-1").join("agent.json");
let rewritten = std::fs::read_to_string(&path)
.expect("read agent.json")
.replacen("\"schema\": 1", "\"schema\": 99", 1);
std::fs::write(&path, rewritten).expect("write future schema");
let error = store
.load_agent("agent-1")
.expect_err("a future schema must be refused");
assert!(error.to_string().contains("schema"), "{error}");
}
fn rule(tool_name: &str, allow: bool, scope: PermissionRuleScope) -> RememberedRule {
RememberedRule {
key: RuleKey {
tool_name: tool_name.to_string(),
pattern: None,
},
allow,
scope,
reason: None,
}
}
fn rules_context() -> PermissionRuleContext {
PermissionRuleContext {
session_id: "session-1".to_owned(),
project_id: Some("project-1".to_owned()),
}
}
fn wait_for_path(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(10);
while !path.exists() {
assert!(Instant::now() < deadline, "timed out waiting for {path:?}");
thread::sleep(Duration::from_millis(5));
}
}
fn wait_for_child(mut child: Child) {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match child.try_wait().expect("poll child test") {
Some(status) => {
assert!(status.success(), "child test failed with {status}");
return;
}
None if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
None => {
let _ = child.kill();
let _ = child.wait();
panic!("child test did not finish within 30 seconds");
}
}
}
}
#[test]
fn permission_rules_round_trip_across_scopes_and_reopen() {
let root = temp_root("rules");
{
let store = FileRuntimeStore::new(&root);
store
.save_rules(
"session-1",
Some("proj-x"),
&[
rule("shell", true, PermissionRuleScope::Session),
rule("read", false, PermissionRuleScope::Project),
rule("write", false, PermissionRuleScope::Global),
],
)
.expect("save rules");
}
let store = FileRuntimeStore::new(&root);
let with_project = store
.load_rules("session-1", Some("proj-x"))
.expect("load with project");
assert_eq!(with_project.len(), 3);
let other_session = store
.load_rules("session-2", None)
.expect("load other session");
assert_eq!(
other_session.len(),
1,
"only the global rule crosses sessions"
);
assert_eq!(other_session[0].key.tool_name, "write");
store.clear_rules("session-1").expect("clear session");
assert!(
store
.load_rules("session-1", Some("proj-x"))
.expect("load after clear")
.is_empty()
);
}
#[test]
fn permission_point_operations_follow_the_shared_store_contract() {
let store = FileRuntimeStore::new(temp_root("rules-point-contract"));
crate::runtime::store::permission_contract::assert_permission_rule_store_contract(&store);
}
#[test]
fn permission_point_operations_survive_file_store_reopen() {
let root = temp_root("rules-point-reopen");
let context = PermissionRuleContext {
session_id: "session-1".to_owned(),
project_id: Some("project-1".to_owned()),
};
{
let store = FileRuntimeStore::new(&root);
for scope in [
PermissionRuleScope::Global,
PermissionRuleScope::Project,
PermissionRuleScope::Session,
] {
store
.upsert_rule(
&context,
&rule("shell", scope != PermissionRuleScope::Project, scope),
)
.expect("upsert point rule");
}
}
let reopened = FileRuntimeStore::new(&root);
let loaded = reopened
.load_applicable_rules(&context)
.expect("load point rules after reopen");
assert_eq!(loaded.len(), 3);
assert_eq!(
loaded.iter().map(|rule| rule.scope).collect::<Vec<_>>(),
vec![
PermissionRuleScope::Session,
PermissionRuleScope::Project,
PermissionRuleScope::Global,
]
);
}
#[test]
fn a_rules_mutation_waits_for_an_independent_stores_sidecar_lock() {
let root = temp_root("rules-independent-lock");
let lock_holder = FileRuntimeStore::new(&root);
let writer = FileRuntimeStore::new(&root);
let file_guard = super::fs_util::lock_exclusive(&lock_holder.rules_lock_path())
.expect("hold the rules sidecar lock");
let (done_tx, done_rx) = mpsc::channel();
let writer_thread = thread::spawn(move || {
done_tx
.send(writer.upsert_rule(
&rules_context(),
&rule("shell", true, PermissionRuleScope::Session),
))
.expect("report writer result");
});
assert!(
done_rx.recv_timeout(Duration::from_millis(100)).is_err(),
"the independent store wrote without waiting for the sidecar lock"
);
assert!(
!lock_holder.rules_path().exists(),
"the rules replacement must not happen while another holder owns the lock"
);
drop(file_guard);
done_rx
.recv_timeout(Duration::from_secs(10))
.expect("writer finishes after lock release")
.expect("write rules");
writer_thread.join().expect("join writer");
}
const RULES_BLOCKED_PROCESS_ROOT_ENV: &str = "MENTRA_TEST_RULES_BLOCKED_PROCESS_ROOT";
#[test]
fn a_process_rule_mutation_waits_for_the_sidecar_lock() {
if let Some(root) = std::env::var_os(RULES_BLOCKED_PROCESS_ROOT_ENV) {
let root = PathBuf::from(root);
let store = FileRuntimeStore::new(&root);
std::fs::write(root.join("child-ready"), b"ready")
.expect("announce immediately before mutation");
store
.upsert_rule(
&rules_context(),
&rule("child", true, PermissionRuleScope::Session),
)
.expect("child writes rule after lock release");
std::fs::write(root.join("child-done"), b"done").expect("announce child completion");
return;
}
let root = temp_root("rules-process-blocking");
let store = FileRuntimeStore::new(&root);
let file_guard = super::fs_util::lock_exclusive(&store.rules_lock_path())
.expect("parent holds the rules sidecar lock");
let executable = std::env::current_exe().expect("locate unit-test executable");
let mut child = Command::new(executable)
.arg("a_process_rule_mutation_waits_for_the_sidecar_lock")
.arg("--nocapture")
.env(RULES_BLOCKED_PROCESS_ROOT_ENV, &root)
.spawn()
.expect("spawn blocked child writer");
wait_for_path(&root.join("child-ready"));
thread::sleep(Duration::from_millis(200));
assert!(
child.try_wait().expect("poll blocked child").is_none(),
"child mutation completed while the parent process held rules.lock"
);
assert!(
!root.join("child-done").exists() && !store.rules_path().exists(),
"the child must not commit its mutation before lock release"
);
drop(file_guard);
wait_for_child(child);
assert!(root.join("child-done").exists());
assert_eq!(
store
.load_applicable_rules(&rules_context())
.expect("load child rule")
.len(),
1
);
}
#[test]
fn clones_share_rule_cache_and_mutation_invalidation() {
let store = FileRuntimeStore::new(temp_root("rules-clone-cache"));
store
.upsert_rule(
&rules_context(),
&rule("shell", true, PermissionRuleScope::Session),
)
.expect("seed cached rules");
let clone = store.clone();
let misses_after_write = store.rules_cache_misses();
assert_eq!(
clone
.load_applicable_rules(&rules_context())
.expect("clone loads rules")
.len(),
1
);
assert_eq!(
store.rules_cache_misses(),
misses_after_write + 1,
"a changed mutation invalidates the cache shared by its clones"
);
let misses_after_first_load = store.rules_cache_misses();
assert_eq!(
store
.load_applicable_rules(&rules_context())
.expect("original reuses clone load")
.len(),
1
);
assert_eq!(store.rules_cache_misses(), misses_after_first_load);
clone
.upsert_rule(
&rules_context(),
&rule("read", false, PermissionRuleScope::Session),
)
.expect("clone updates rules");
let misses_after_clone_write = store.rules_cache_misses();
assert_eq!(
store
.load_applicable_rules(&rules_context())
.expect("original loads clone update")
.len(),
2
);
assert_eq!(
store.rules_cache_misses(),
misses_after_clone_write + 1,
"a clone mutation invalidates the cache shared by the original"
);
let misses_after_reload = store.rules_cache_misses();
assert_eq!(
clone
.load_applicable_rules(&rules_context())
.expect("clone reuses original reload")
.len(),
2
);
assert_eq!(store.rules_cache_misses(), misses_after_reload);
}
#[test]
fn unchanged_rule_reads_reuse_cache_and_independent_writes_invalidate_it() {
let root = temp_root("rules-independent-cache");
let writer = FileRuntimeStore::new(&root);
writer
.upsert_rule(
&rules_context(),
&rule("short", true, PermissionRuleScope::Session),
)
.expect("seed rules");
let reader = FileRuntimeStore::new(&root);
assert_eq!(
reader
.load_applicable_rules(&rules_context())
.expect("first load")
.len(),
1
);
assert_eq!(reader.rules_cache_misses(), 1);
assert_eq!(
reader
.load_applicable_rules(&rules_context())
.expect("cached load")
.len(),
1
);
assert_eq!(
reader.rules_cache_misses(),
1,
"an unchanged identity avoids a second whole-file read and parse"
);
writer
.upsert_rule(
&rules_context(),
&rule(
"a-much-longer-tool-name-that-changes-the-file-length",
false,
PermissionRuleScope::Session,
),
)
.expect("independent store updates rules");
assert_eq!(
reader
.load_applicable_rules(&rules_context())
.expect("load independent update")
.len(),
2
);
assert_eq!(
reader.rules_cache_misses(),
2,
"a replacement by an independent store invalidates the cache"
);
}
#[test]
fn a_mutation_reloads_disk_instead_of_extending_its_stale_cache() {
let root = temp_root("rules-mutation-cache");
let first = FileRuntimeStore::new(&root);
let second = FileRuntimeStore::new(&root);
first
.upsert_rule(
&rules_context(),
&rule("first", true, PermissionRuleScope::Session),
)
.expect("write first rule");
second
.load_applicable_rules(&rules_context())
.expect("prime second store cache");
first
.upsert_rule(
&rules_context(),
&rule("external-to-cache", false, PermissionRuleScope::Session),
)
.expect("write through first store");
second
.upsert_rule(
&rules_context(),
&rule("second", true, PermissionRuleScope::Session),
)
.expect("extend the authoritative disk snapshot");
assert_eq!(
FileRuntimeStore::new(&root)
.load_applicable_rules(&rules_context())
.expect("load final rules")
.len(),
3,
"a mutation must not overwrite an independent update with cached state"
);
}
const RULES_PROCESS_ROOT_ENV: &str = "MENTRA_TEST_RULES_PROCESS_ROOT";
const RULES_PROCESS_WRITER_ENV: &str = "MENTRA_TEST_RULES_PROCESS_WRITER";
#[test]
fn concurrent_process_rule_mutations_preserve_every_row() {
if let (Some(root), Ok(writer)) = (
std::env::var_os(RULES_PROCESS_ROOT_ENV),
std::env::var(RULES_PROCESS_WRITER_ENV),
) {
let root = PathBuf::from(root);
std::fs::write(root.join(format!("{writer}.ready")), b"ready")
.expect("announce child writer");
wait_for_path(&root.join("start"));
let store = FileRuntimeStore::new(&root);
for index in 0..32 {
store
.upsert_rule(
&rules_context(),
&rule(
&format!("{writer}-{index}"),
true,
PermissionRuleScope::Session,
),
)
.expect("child upserts rule");
}
return;
}
let root = temp_root("rules-process-lock");
std::fs::create_dir_all(&root).expect("create process-test root");
let reader = FileRuntimeStore::new(&root);
assert!(
reader
.load_applicable_rules(&rules_context())
.expect("prime missing-file cache")
.is_empty()
);
let executable = std::env::current_exe().expect("locate unit-test executable");
let spawn_writer = |writer: &str| {
Command::new(&executable)
.arg("concurrent_process_rule_mutations_preserve_every_row")
.arg("--nocapture")
.env(RULES_PROCESS_ROOT_ENV, &root)
.env(RULES_PROCESS_WRITER_ENV, writer)
.spawn()
.expect("spawn child writer")
};
let first = spawn_writer("first");
let second = spawn_writer("second");
wait_for_path(&root.join("first.ready"));
wait_for_path(&root.join("second.ready"));
std::fs::write(root.join("start"), b"start").expect("release child writers");
wait_for_child(first);
wait_for_child(second);
let stored = reader
.load_applicable_rules(&rules_context())
.expect("load all child rules");
assert_eq!(
stored.len(),
64,
"each successful cross-process update must remain in rules.json"
);
assert_eq!(
reader.rules_cache_misses(),
2,
"the reader invalidates its missing-file cache after child-process writes"
);
}
#[test]
fn file_store_legacy_duplicates_load_fail_safe_and_revoke_all_rows() {
let root = temp_root("rules-legacy-duplicates");
std::fs::create_dir_all(&root).expect("create store root");
let store = FileRuntimeStore::new(&root);
let row = |session_id: &str, allow: bool, reason: Option<&str>| {
serde_json::json!({
"session_id": session_id,
"rule": {
"key": { "tool_name": "shell", "pattern": null },
"allow": allow,
"scope": "global",
"reason": reason,
}
})
};
std::fs::write(
store.rules_path(),
serde_json::to_vec_pretty(&serde_json::json!({
"schema": super::SCHEMA_VERSION,
"rules": [
row("legacy-allow", true, Some("allowed")),
row("legacy-reasonless", false, None),
row("legacy-zeta", false, Some("zeta")),
row("legacy-alpha", false, Some("alpha")),
]
}))
.expect("serialize legacy rules"),
)
.expect("write legacy rules");
let context = PermissionRuleContext {
session_id: "current".to_owned(),
project_id: None,
};
let loaded = store
.load_applicable_rules(&context)
.expect("load legacy duplicates");
assert_eq!(loaded.len(), 1);
assert!(!loaded[0].allow);
assert_eq!(loaded[0].reason.as_deref(), Some("alpha"));
let address = PermissionRuleAddress {
scope: PermissionRuleScope::Global,
key: loaded[0].key.clone(),
};
assert!(
store
.revoke_rule(&context, &address)
.expect("revoke legacy duplicates")
);
let on_disk: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(store.rules_path()).expect("read rules after revoke"),
)
.expect("parse rules after revoke");
assert!(on_disk["rules"].as_array().expect("rules array").is_empty());
std::fs::write(
store.rules_path(),
serde_json::to_vec_pretty(&serde_json::json!({
"schema": super::SCHEMA_VERSION,
"rules": [
row("legacy-one", false, Some("one")),
row("legacy-two", false, Some("two")),
]
}))
.expect("serialize clear fixture"),
)
.expect("write clear fixture");
assert_eq!(
store
.clear_scope(&context, PermissionRuleScope::Global)
.expect("clear duplicate namespace"),
2
);
}
#[test]
fn repeated_saves_keep_one_copy_of_each_rule() {
let store = FileRuntimeStore::new(temp_root("rules-dedup"));
let remembered = [
rule("shell", true, PermissionRuleScope::Session),
rule("read", false, PermissionRuleScope::Project),
rule("write", false, PermissionRuleScope::Global),
];
for _ in 0..4 {
store
.save_rules("session-1", Some("proj-x"), &remembered)
.expect("save rules");
}
let loaded = store
.load_rules("session-1", Some("proj-x"))
.expect("load rules");
assert_eq!(loaded.len(), 3, "each rule loads exactly once: {loaded:?}");
let on_disk: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(store.rules_path()).expect("read rules.json"),
)
.expect("parse rules.json");
assert_eq!(
on_disk["rules"].as_array().expect("rules array").len(),
3,
"the file holds one copy of each rule"
);
}
#[test]
fn saving_rules_replaces_only_that_sessions_session_scope() {
let store = FileRuntimeStore::new(temp_root("rules-replace"));
store
.save_rules(
"session-1",
Some("proj-x"),
&[
rule("shell", true, PermissionRuleScope::Session),
rule("read", false, PermissionRuleScope::Project),
],
)
.expect("save initial");
store
.save_rules(
"session-1",
Some("proj-x"),
&[rule("write", false, PermissionRuleScope::Session)],
)
.expect("save replacement");
let loaded = store
.load_rules("session-1", Some("proj-x"))
.expect("load rules");
let mut tools: Vec<_> = loaded
.iter()
.map(|rule| rule.key.tool_name.as_str())
.collect();
tools.sort_unstable();
assert_eq!(
tools,
vec!["read", "write"],
"the project rule survives a session-scope replacement"
);
}
#[test]
fn run_lifecycle_is_an_append_only_event_log() {
let store = FileRuntimeStore::new(temp_root("runs"));
let run_id = store.start_run("agent-1").expect("start run");
store.finish_run(&run_id).expect("finish run");
store
.fail_run("run-from-a-previous-process", "interrupted")
.expect("a transition for an unseen run id is still recorded");
let contents = std::fs::read_to_string(store.runs_path()).expect("read runs.jsonl");
let events: Vec<serde_json::Value> = contents
.lines()
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_str(line).expect("event line parses"))
.collect();
assert_eq!(events.len(), 3);
assert_eq!(events[0]["run_id"], serde_json::json!(run_id));
assert_eq!(events[0]["state"], serde_json::json!("running"));
assert_eq!(events[0]["agent_id"], serde_json::json!("agent-1"));
assert_eq!(events[1]["state"], serde_json::json!("finished"));
assert_eq!(events[2]["state"], serde_json::json!("failed"));
assert_eq!(events[2]["error"], serde_json::json!("interrupted"));
}
#[test]
fn long_term_memory_is_refused_with_the_fix_named() {
let store = FileRuntimeStore::new(temp_root("memory"));
let error = store
.upsert_records(&[])
.expect_err("the file store refuses long-term memory");
assert!(error.to_string().contains("store-sqlite"), "{error}");
assert!(
store.search_records("agent-1", "anything", 5).is_err(),
"search is refused the same way"
);
}
#[test]
fn leases_exclude_independent_stores_until_released() {
let root = temp_root("leases");
let first = FileRuntimeStore::new(&root);
let second = FileRuntimeStore::new(&root);
let ttl = Duration::from_secs(3600);
assert!(
first
.acquire_lease("agent:x", "runtime-1", ttl)
.expect("acquire")
);
assert!(
!first
.acquire_lease("agent:x", "runtime-1", ttl)
.expect("re-acquire"),
"a held lease refuses even its own owner, as the SQLite store does"
);
assert!(
!second
.acquire_lease("agent:x", "runtime-2", ttl)
.expect("contended acquire"),
"the file lock excludes an independent store on the same root"
);
first
.release_lease("agent:x", "runtime-9")
.expect("mismatched release");
assert!(
!second
.acquire_lease("agent:x", "runtime-2", ttl)
.expect("still held")
);
first
.release_lease("agent:x", "runtime-1")
.expect("release");
assert!(
second
.acquire_lease("agent:x", "runtime-2", ttl)
.expect("acquire after release"),
"a released lease is immediately acquirable elsewhere"
);
}
#[test]
fn tasks_work_in_process() {
let store = FileRuntimeStore::new(temp_root("volatile"));
let namespace = std::path::Path::new("/tasks/example");
store
.replace_tasks(namespace, &[])
.expect("task board is usable");
assert!(store.load_tasks(namespace).expect("load tasks").is_empty());
}
#[cfg(not(feature = "store-sqlite"))]
#[test]
fn an_existing_sqlite_store_is_diagnosed_not_shadowed() {
let root = temp_root("migration");
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("runtime.sqlite"), b"SQLite format 3\0").expect("plant database");
let store = FileRuntimeStore::new(&root);
let error = store
.prepare_recovery()
.expect_err("an unreadable existing store must be named, not shadowed");
let message = error.to_string();
assert!(message.contains("runtime.sqlite"), "{message}");
assert!(message.contains("store-sqlite"), "{message}");
}
#[test]
fn prepare_recovery_creates_the_store_home() {
let root = temp_root("recovery");
let store = FileRuntimeStore::new(&root);
assert!(!root.exists(), "construction alone touches nothing");
store.prepare_recovery().expect("prepare recovery");
assert!(store.agents_dir().is_dir());
}