use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use crate::{
agent::{AgentConfig, AgentStatus, SpawnedAgentSummary, TeammateIdentity},
background::BackgroundStore,
memory::MemoryStore,
memory::journal::AgentMemoryState,
provider::ProviderId,
runtime::TaskItem,
session::{PermissionRuleAddress, PermissionRuleScope, RememberedRule, RuleStore},
team::TeamStore,
};
use super::error::RuntimeError;
#[cfg(test)]
pub(crate) mod permission_contract;
static NEXT_STORE_ID: AtomicU64 = AtomicU64::new(1);
#[cfg(test)]
static NEXT_TEST_STORE_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedAgentRecord {
pub(crate) id: String,
pub(crate) runtime_identifier: String,
pub(crate) name: String,
pub(crate) model: String,
pub(crate) provider_id: ProviderId,
pub(crate) config: AgentConfig,
pub(crate) hidden_tools: HashSet<String>,
pub(crate) max_rounds: Option<usize>,
pub(crate) teammate_identity: Option<TeammateIdentity>,
pub(crate) rounds_since_task: usize,
pub(crate) idle_requested: bool,
pub(crate) status: AgentStatus,
pub(crate) subagents: Vec<SpawnedAgentSummary>,
}
#[derive(Debug, Clone)]
pub struct LoadedAgentState {
pub(crate) record: PersistedAgentRecord,
pub(crate) memory: AgentMemoryState,
pub(crate) created_at: Option<u64>,
pub(crate) updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStateSnapshot {
pub(crate) tasks: Vec<TaskItem>,
}
pub trait AgentStore: Send + Sync {
fn allows_disk_artifacts(&self) -> bool {
true
}
fn prepare_recovery(&self) -> Result<(), RuntimeError>;
fn create_agent(
&self,
record: &PersistedAgentRecord,
memory: &AgentMemoryState,
) -> Result<(), RuntimeError>;
fn save_agent_record(&self, record: &PersistedAgentRecord) -> Result<(), RuntimeError>;
fn save_agent_memory(
&self,
agent_id: &str,
memory: &AgentMemoryState,
) -> Result<(), RuntimeError>;
fn load_agent(&self, agent_id: &str) -> Result<Option<LoadedAgentState>, RuntimeError>;
fn delete_agent(&self, agent_id: &str) -> Result<(), RuntimeError>;
fn list_agents(&self) -> Result<Vec<LoadedAgentState>, RuntimeError>;
fn list_agents_by_runtime(
&self,
runtime_identifier: &str,
) -> Result<Vec<LoadedAgentState>, RuntimeError>;
}
pub trait RunStore: Send + Sync {
fn start_run(&self, agent_id: &str) -> Result<String, RuntimeError>;
fn update_run_state(
&self,
run_id: &str,
state: &str,
error: Option<&str>,
) -> Result<(), RuntimeError>;
fn finish_run(&self, run_id: &str) -> Result<(), RuntimeError>;
fn fail_run(&self, run_id: &str, error: &str) -> Result<(), RuntimeError>;
}
pub trait TaskStore: Send + Sync {
fn load_tasks(&self, namespace: &Path) -> Result<Vec<TaskItem>, RuntimeError>;
fn capture_tasks(&self, namespace: &Path) -> Result<TaskStateSnapshot, RuntimeError>;
fn restore_tasks(
&self,
namespace: &Path,
snapshot: &TaskStateSnapshot,
) -> Result<(), RuntimeError>;
fn replace_tasks(&self, namespace: &Path, tasks: &[TaskItem]) -> Result<(), RuntimeError>;
fn mutate(
&self,
namespace: &Path,
mutation: &mut dyn FnMut(&mut Vec<TaskItem>) -> Result<(), RuntimeError>,
) -> Result<(), RuntimeError> {
let mut tasks = self.load_tasks(namespace)?;
mutation(&mut tasks)?;
self.replace_tasks(namespace, &tasks)
}
}
pub trait AuditStore: Send + Sync {
fn record_audit_event(
&self,
scope: &str,
event_type: &str,
payload: serde_json::Value,
) -> Result<(), RuntimeError>;
}
pub trait LeaseStore: Send + Sync {
fn acquire_lease(&self, key: &str, owner: &str, ttl: Duration) -> Result<bool, RuntimeError>;
fn release_lease(&self, key: &str, owner: &str) -> Result<(), RuntimeError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PermissionRuleContext {
pub session_id: String,
pub project_id: Option<String>,
}
impl PermissionRuleContext {
pub(crate) fn validate_scope(&self, scope: PermissionRuleScope) -> Result<(), RuntimeError> {
if scope == PermissionRuleScope::Project && self.project_id.is_none() {
return Err(RuntimeError::OperationDenied(
"project-scoped permission rules require a project_id".to_string(),
));
}
Ok(())
}
pub(crate) fn validate_persisted_scope(
&self,
scope: PermissionRuleScope,
) -> Result<(), RuntimeError> {
self.validate_scope(scope)?;
if scope == PermissionRuleScope::Process {
return Err(RuntimeError::OperationDenied(
"process-scoped permission rules belong to a live session and cannot be persisted"
.to_string(),
));
}
Ok(())
}
}
fn compare_duplicate_rules(left: &RememberedRule, right: &RememberedRule) -> std::cmp::Ordering {
left.allow
.cmp(&right.allow)
.then_with(|| match (&left.reason, &right.reason) {
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(left, right) => left.cmp(right),
})
.then_with(|| left.key.tool_name.cmp(&right.key.tool_name))
.then_with(|| left.key.pattern.cmp(&right.key.pattern))
}
pub(crate) fn canonicalize_permission_rules(
rules: impl IntoIterator<Item = RememberedRule>,
) -> Vec<RememberedRule> {
let mut unique: HashMap<PermissionRuleAddress, RememberedRule> = HashMap::new();
for rule in rules {
let address = PermissionRuleAddress::from(&rule);
match unique.entry(address) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(rule);
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
if compare_duplicate_rules(&rule, entry.get()).is_lt() {
entry.insert(rule);
}
}
}
}
let store = RuleStore::new();
for rule in unique.into_values() {
store.add_rule(rule);
}
store.rules()
}
pub trait PermissionRuleStore: Send + Sync {
fn upsert_rule(
&self,
context: &PermissionRuleContext,
rule: &RememberedRule,
) -> Result<(), RuntimeError>;
fn load_applicable_rules(
&self,
context: &PermissionRuleContext,
) -> Result<Vec<RememberedRule>, RuntimeError>;
fn revoke_rule(
&self,
context: &PermissionRuleContext,
address: &PermissionRuleAddress,
) -> Result<bool, RuntimeError>;
fn clear_scope(
&self,
context: &PermissionRuleContext,
scope: PermissionRuleScope,
) -> Result<usize, RuntimeError>;
fn save_rules(
&self,
session_id: &str,
project_id: Option<&str>,
rules: &[RememberedRule],
) -> Result<(), RuntimeError>;
fn load_rules(
&self,
session_id: &str,
project_id: Option<&str>,
) -> Result<Vec<RememberedRule>, RuntimeError> {
self.load_applicable_rules(&PermissionRuleContext {
session_id: session_id.to_owned(),
project_id: project_id.map(str::to_owned),
})
}
fn clear_rules(&self, session_id: &str) -> Result<(), RuntimeError>;
}
pub trait RuntimeStore:
AgentStore
+ RunStore
+ TaskStore
+ AuditStore
+ LeaseStore
+ PermissionRuleStore
+ TeamStore
+ BackgroundStore
+ MemoryStore
+ Send
+ Sync
{
}
impl<T> RuntimeStore for T where
T: AgentStore
+ RunStore
+ TaskStore
+ AuditStore
+ LeaseStore
+ PermissionRuleStore
+ TeamStore
+ BackgroundStore
+ MemoryStore
+ Send
+ Sync
{
}
pub(crate) fn next_id(prefix: &str) -> String {
let counter = NEXT_STORE_ID.fetch_add(1, Ordering::Relaxed);
format!("{prefix}-{:x}-{:x}", now_nanos(), counter)
}
pub(crate) fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub(crate) fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
}
#[cfg(not(test))]
pub(crate) fn default_store_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().root_dir
}
#[cfg(test)]
thread_local! {
static DEFAULT_STORE_DIRS: std::cell::RefCell<Vec<PathBuf>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(test)]
pub(crate) fn default_store_dir() -> PathBuf {
let suffix = NEXT_TEST_STORE_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join("mentra-test-runtime")
.join(format!("process-{}-{suffix}", std::process::id()));
DEFAULT_STORE_DIRS.with(|dirs| dirs.borrow_mut().push(dir.clone()));
dir
}
#[cfg(test)]
pub(crate) fn default_store_paths_on_this_thread() -> Vec<PathBuf> {
#[cfg(feature = "store-sqlite")]
let file_name = "runtime.sqlite";
#[cfg(not(feature = "store-sqlite"))]
let file_name = "agents";
DEFAULT_STORE_DIRS.with(|dirs| {
dirs.borrow()
.iter()
.map(|dir| dir.join(file_name))
.collect()
})
}