use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
Message,
memory::journal::{AgentMemoryState, CompactionState, PendingTurnState, RunMemoryState},
transcript::{AgentTranscript, EntryId, TranscriptItem},
};
use super::{
super::store::{AgentStore, LoadedAgentState, PersistedAgentRecord, now_secs},
FileRuntimeStore, RuntimeError, SCHEMA_VERSION, fs_util, lock_unpoisoned, parse_versioned,
store_error, to_pretty_json, transcript_log,
};
const AGENT_FILE: &str = "agent.json";
const STATE_FILE: &str = "state.json";
const TRANSCRIPT_FILE: &str = "transcript.jsonl";
const LEAF_FILE: &str = "leaf";
#[derive(Serialize, Deserialize)]
struct AgentFile {
schema: u32,
created_at: u64,
updated_at: u64,
record: PersistedAgentRecord,
}
#[derive(Serialize, Deserialize)]
struct StateFile {
schema: u32,
revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pending_turn: Option<PendingTurnState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
resumable_user_message: Option<Message>,
#[serde(default)]
compaction: CompactionState,
transcript: TranscriptShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
run: Option<RunFile>,
}
#[derive(Serialize, Deserialize)]
struct TranscriptShape {
items: Vec<EntryId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
archive: Vec<EntryId>,
}
#[derive(Serialize, Deserialize)]
struct RunFile {
run_id: String,
assistant_committed: bool,
baseline: TranscriptShape,
}
impl AgentStore for FileRuntimeStore {
fn prepare_recovery(&self) -> Result<(), RuntimeError> {
#[cfg(not(feature = "store-sqlite"))]
{
let sqlite_db = self.root().join("runtime.sqlite");
if sqlite_db.exists() {
return Err(RuntimeError::Store(format!(
"'{}' holds an existing SQLite runtime store, but this build compiled \
mentra without the `store-sqlite` feature; enable that feature to keep \
reading it, or point the file store at a different root",
sqlite_db.display()
)));
}
}
let agents_dir = self.agents_dir();
std::fs::create_dir_all(&agents_dir)
.map_err(|error| store_error(&format!("create '{}'", agents_dir.display()), error))
}
fn create_agent(
&self,
record: &PersistedAgentRecord,
memory: &AgentMemoryState,
) -> Result<(), RuntimeError> {
self.save_memory(&record.id, memory)?;
self.save_record(record)
}
fn save_agent_record(&self, record: &PersistedAgentRecord) -> Result<(), RuntimeError> {
self.save_record(record)
}
fn save_agent_memory(
&self,
agent_id: &str,
memory: &AgentMemoryState,
) -> Result<(), RuntimeError> {
self.save_memory(agent_id, memory)
}
fn load_agent(&self, agent_id: &str) -> Result<Option<LoadedAgentState>, RuntimeError> {
let dir = self.agent_dir(agent_id);
let Some(contents) = fs_util::read_optional(&dir.join(AGENT_FILE))? else {
return Ok(None);
};
let agent_file: AgentFile = parse_versioned(&contents, AGENT_FILE)?;
Ok(Some(finish_load(&dir, agent_file)?))
}
fn delete_agent(&self, agent_id: &str) -> Result<(), RuntimeError> {
self.forget_transcript_log(agent_id);
let dir = self.agent_dir(agent_id);
let agent_path = dir.join(AGENT_FILE);
match std::fs::remove_file(&agent_path) {
Ok(()) => fs_util::fsync_dir(&dir)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(store_error(
&format!("remove '{}'", agent_path.display()),
error,
));
}
}
match std::fs::remove_dir_all(&dir) {
Ok(()) => fs_util::fsync_dir(&self.agents_dir()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(store_error(&format!("remove '{}'", dir.display()), error)),
}
}
fn list_agents(&self) -> Result<Vec<LoadedAgentState>, RuntimeError> {
let agents_dir = self.agents_dir();
let entries = match std::fs::read_dir(&agents_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(store_error(
&format!("list '{}'", agents_dir.display()),
error,
));
}
};
let mut agents = Vec::new();
for entry in entries {
let entry = entry
.map_err(|error| store_error(&format!("list '{}'", agents_dir.display()), error))?;
let dir = entry.path();
let agent_path = dir.join(AGENT_FILE);
if !dir.is_dir() || !agent_path.is_file() {
continue;
}
let Some(contents) = fs_util::read_optional(&agent_path)? else {
continue;
};
let agent_file: AgentFile = parse_versioned(&contents, AGENT_FILE)?;
agents.push(finish_load(&dir, agent_file)?);
}
agents.sort_by(|a, b| (a.created_at, &a.record.id).cmp(&(b.created_at, &b.record.id)));
Ok(agents)
}
fn list_agents_by_runtime(
&self,
runtime_identifier: &str,
) -> Result<Vec<LoadedAgentState>, RuntimeError> {
Ok(self
.list_agents()?
.into_iter()
.filter(|loaded| loaded.record.runtime_identifier == runtime_identifier)
.collect())
}
}
impl FileRuntimeStore {
fn save_record(&self, record: &PersistedAgentRecord) -> Result<(), RuntimeError> {
let path = self.agent_dir(&record.id).join(AGENT_FILE);
let now = now_secs() as u64;
let created_at = match fs_util::read_optional(&path)? {
Some(contents) => parse_versioned::<AgentFile>(&contents, AGENT_FILE)?.created_at,
None => now,
};
let file = AgentFile {
schema: SCHEMA_VERSION,
created_at,
updated_at: now,
record: record.clone(),
};
fs_util::atomic_replace(&path, to_pretty_json(&file)?.as_bytes())
}
fn save_memory(&self, agent_id: &str, memory: &AgentMemoryState) -> Result<(), RuntimeError> {
let dir = self.agent_dir(agent_id);
let log = self.transcript_log(agent_id);
let mut index = lock_unpoisoned(&log);
index.append_missing(&dir.join(TRANSCRIPT_FILE), entries_of(memory))?;
let state_file = decompose_memory(memory);
fs_util::atomic_replace(
&dir.join(STATE_FILE),
to_pretty_json(&state_file)?.as_bytes(),
)?;
let leaf_path = dir.join(LEAF_FILE);
match memory.transcript.leaf() {
Some(leaf) => {
fs_util::atomic_replace(&leaf_path, format!("{leaf}\n").as_bytes())?;
}
None => match std::fs::remove_file(&leaf_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(store_error(
&format!("remove '{}'", leaf_path.display()),
error,
));
}
},
}
Ok(())
}
}
fn finish_load(
dir: &std::path::Path,
agent_file: AgentFile,
) -> Result<LoadedAgentState, RuntimeError> {
let Some(state_contents) = fs_util::read_optional(&dir.join(STATE_FILE))? else {
return Err(RuntimeError::Store(format!(
"Agent '{}' is missing persisted memory",
agent_file.record.id
)));
};
let state_file: StateFile = parse_versioned(&state_contents, STATE_FILE)?;
let entries = transcript_log::read_log(&dir.join(TRANSCRIPT_FILE))?;
let memory = compose_memory(state_file, &entries)?;
Ok(LoadedAgentState {
record: agent_file.record,
memory,
created_at: Some(agent_file.created_at),
updated_at: Some(agent_file.updated_at),
})
}
fn entries_of(memory: &AgentMemoryState) -> impl Iterator<Item = &TranscriptItem> {
let baseline = memory.run.as_ref().map(|run| &run.baseline_transcript);
memory
.transcript
.items()
.iter()
.chain(memory.transcript.archived())
.chain(
baseline
.into_iter()
.flat_map(|transcript| transcript.items().iter().chain(transcript.archived())),
)
}
fn decompose_memory(memory: &AgentMemoryState) -> StateFile {
StateFile {
schema: SCHEMA_VERSION,
revision: memory.revision,
pending_turn: memory.pending_turn.clone(),
resumable_user_message: memory.resumable_user_message.clone(),
compaction: memory.compaction.clone(),
transcript: shape_of(&memory.transcript),
run: memory.run.as_ref().map(|run| RunFile {
run_id: run.run_id.clone(),
assistant_committed: run.assistant_committed,
baseline: shape_of(&run.baseline_transcript),
}),
}
}
fn compose_memory(
state: StateFile,
entries: &HashMap<String, TranscriptItem>,
) -> Result<AgentMemoryState, RuntimeError> {
Ok(AgentMemoryState {
transcript: transcript_from(&state.transcript, entries)?,
pending_turn: state.pending_turn,
resumable_user_message: state.resumable_user_message,
compaction: state.compaction,
revision: state.revision,
run: state
.run
.map(|run| {
Ok::<_, RuntimeError>(RunMemoryState {
run_id: run.run_id,
baseline_transcript: transcript_from(&run.baseline, entries)?,
assistant_committed: run.assistant_committed,
})
})
.transpose()?,
})
}
fn shape_of(transcript: &AgentTranscript) -> TranscriptShape {
TranscriptShape {
items: transcript
.items()
.iter()
.map(|item| item.id.clone())
.collect(),
archive: transcript
.archived()
.iter()
.map(|item| item.id.clone())
.collect(),
}
}
fn transcript_from(
shape: &TranscriptShape,
entries: &HashMap<String, TranscriptItem>,
) -> Result<AgentTranscript, RuntimeError> {
let resolve = |id: &EntryId| {
entries.get(id.as_str()).cloned().ok_or_else(|| {
RuntimeError::Store(format!(
"transcript entry '{id}' is named by state.json but missing from transcript.jsonl"
))
})
};
Ok(AgentTranscript::from_parts(
shape.items.iter().map(resolve).collect::<Result<_, _>>()?,
shape
.archive
.iter()
.map(resolve)
.collect::<Result<_, _>>()?,
))
}