use std::fmt::{self, Display, Formatter};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::auth::peer_keys::KEY_FILE_NAME;
use crate::schedule_wiring::SCHEDULE_STORE_FILE;
use crate::workgraph_wiring::WORKGRAPH_STORE_FILE;
pub const CANONICAL_SESSIONS_DB_FILE_NAME: &str = "sessions.sqlite3";
pub const RUNTIME_DB_FILE_NAME: &str = "runtime.sqlite";
pub const CANONICAL_CONTINUITY_DB_FILE_NAME: &str = "continuity.sqlite3";
pub const CANONICAL_METADATA_DB_FILE_NAME: &str = "mobkit_metadata.sqlite3";
pub const CANONICAL_CONSOLE_DB_FILE_NAME: &str = "mobkit_console.sqlite3";
pub const CANONICAL_AGENT_MEMORY_DIR_NAME: &str = "agent-memory";
pub const EVENT_LOG_DB_FILE_NAME: &str = "event_log.sqlite3";
pub const BLOB_ROOT_DIR_NAME: &str = "blobs";
pub const RUNTIME_REGISTRY_FILE_NAME: &str = "tux-runtimes.json";
pub const GATEWAY_HOME_DIR_NAME: &str = "meerkat-mobkit";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DatabaseSlot {
Sessions,
Runtime,
Schedule,
Workgraph,
Continuity,
Metadata,
Console,
AgentMemory,
EventLog,
}
impl DatabaseSlot {
pub const ALL: [Self; 9] = [
Self::Sessions,
Self::Runtime,
Self::Schedule,
Self::Workgraph,
Self::Continuity,
Self::Metadata,
Self::Console,
Self::AgentMemory,
Self::EventLog,
];
pub fn canonical_name(self) -> &'static str {
match self {
Self::Sessions => CANONICAL_SESSIONS_DB_FILE_NAME,
Self::Runtime => RUNTIME_DB_FILE_NAME,
Self::Schedule => SCHEDULE_STORE_FILE,
Self::Workgraph => WORKGRAPH_STORE_FILE,
Self::Continuity => CANONICAL_CONTINUITY_DB_FILE_NAME,
Self::Metadata => CANONICAL_METADATA_DB_FILE_NAME,
Self::Console => CANONICAL_CONSOLE_DB_FILE_NAME,
Self::AgentMemory => CANONICAL_AGENT_MEMORY_DIR_NAME,
Self::EventLog => EVENT_LOG_DB_FILE_NAME,
}
}
pub fn legacy_names(self) -> &'static [&'static str] {
match self {
Self::Sessions => &["sessions.db", "sessions.sqlite"],
Self::Continuity => &["continuity.db", "identity_continuity.sqlite"],
Self::Metadata => &["mobkit_metadata.sqlite"],
Self::Console => &["mobkit_console.sqlite"],
Self::AgentMemory => &["agent-memory-sqlite"],
Self::Runtime | Self::Schedule | Self::Workgraph | Self::EventLog => &[],
}
}
}
impl Display for DatabaseSlot {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Sessions => "sessions",
Self::Runtime => "runtime",
Self::Schedule => "schedule",
Self::Workgraph => "workgraph",
Self::Continuity => "continuity",
Self::Metadata => "metadata",
Self::Console => "console",
Self::AgentMemory => "agent-memory",
Self::EventLog => "event-log",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
pub enum DatabaseProvenance {
Canonical,
LegacySpelling(String),
ExplicitOverride,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedDatabase {
pub path: PathBuf,
pub provenance: DatabaseProvenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateDirDurability {
Durable,
DeclaredEphemeral,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageLayoutError {
FileNameTwins {
slot: DatabaseSlot,
paths: Vec<PathBuf>,
},
GatewayHomeUnavailable,
}
impl Display for StorageLayoutError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::FileNameTwins { slot, paths } => {
write!(
f,
"file-name twins for the {slot} store: multiple spellings exist in the same \
state directory ("
)?;
for (index, path) in paths.iter().enumerate() {
if index > 0 {
write!(f, ", ")?;
}
write!(f, "{}", path.display())?;
}
write!(
f,
"); refusing to pick one at open — run the storage doctor \
(mobkit/storage/doctor) to inspect, and converge spellings with the \
storage migrate verb"
)
}
Self::GatewayHomeUnavailable => {
write!(
f,
"cannot derive the gateway home: neither XDG_STATE_HOME nor HOME is set"
)
}
}
}
}
impl std::error::Error for StorageLayoutError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MobKitStorageLayout {
state_dir: PathBuf,
gateway_home: Option<PathBuf>,
session_db_override: Option<PathBuf>,
durability: StateDirDurability,
meerkat_state_root: Option<PathBuf>,
}
impl MobKitStorageLayout {
pub fn from_meerkat_layout(
meerkat_layout: &meerkat_core::StorageLayout,
state_dir: PathBuf,
) -> Self {
Self {
state_dir,
gateway_home: None,
session_db_override: None,
durability: StateDirDurability::Durable,
meerkat_state_root: Some(meerkat_layout.state_root().to_path_buf()),
}
}
pub fn standalone(state_dir: PathBuf, gateway_home: PathBuf) -> Self {
Self {
state_dir,
gateway_home: Some(gateway_home),
session_db_override: None,
durability: StateDirDurability::Durable,
meerkat_state_root: None,
}
}
pub fn standalone_from_store_path(store_path: &Path, gateway_home: PathBuf) -> Self {
if store_path.extension().is_some() {
let state_dir = store_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
Self {
state_dir,
gateway_home: Some(gateway_home),
session_db_override: Some(store_path.to_path_buf()),
durability: StateDirDurability::Durable,
meerkat_state_root: None,
}
} else {
Self::standalone(store_path.to_path_buf(), gateway_home)
}
}
pub fn declared_ephemeral(scratch_root: PathBuf) -> Self {
Self {
state_dir: scratch_root,
gateway_home: None,
session_db_override: None,
durability: StateDirDurability::DeclaredEphemeral,
meerkat_state_root: None,
}
}
pub fn with_injected_roots(state_dir: PathBuf, gateway_home: Option<PathBuf>) -> Self {
Self {
state_dir,
gateway_home,
session_db_override: None,
durability: StateDirDurability::Durable,
meerkat_state_root: None,
}
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub fn gateway_home(&self) -> Option<&Path> {
self.gateway_home.as_deref()
}
pub fn meerkat_state_root(&self) -> Option<&Path> {
self.meerkat_state_root.as_deref()
}
pub fn session_db_override(&self) -> Option<&Path> {
self.session_db_override.as_deref()
}
pub fn is_declared_ephemeral(&self) -> bool {
self.durability == StateDirDurability::DeclaredEphemeral
}
pub fn resolve_database(
&self,
slot: DatabaseSlot,
) -> Result<ResolvedDatabase, StorageLayoutError> {
if slot == DatabaseSlot::Sessions
&& let Some(override_path) = self.session_db_override.as_ref()
{
return Ok(ResolvedDatabase {
path: override_path.clone(),
provenance: DatabaseProvenance::ExplicitOverride,
});
}
let canonical = self.state_dir.join(slot.canonical_name());
let existing_legacy: Vec<(&'static str, PathBuf)> = slot
.legacy_names()
.iter()
.map(|name| (*name, self.state_dir.join(name)))
.filter(|(_, path)| path.exists())
.collect();
let canonical_exists = canonical.exists();
if (canonical_exists && !existing_legacy.is_empty()) || existing_legacy.len() > 1 {
let mut paths = Vec::with_capacity(existing_legacy.len() + 1);
if canonical_exists {
paths.push(canonical);
}
paths.extend(existing_legacy.into_iter().map(|(_, path)| path));
return Err(StorageLayoutError::FileNameTwins { slot, paths });
}
if let Some((name, path)) = existing_legacy.into_iter().next() {
return Ok(ResolvedDatabase {
path,
provenance: DatabaseProvenance::LegacySpelling(name.to_string()),
});
}
Ok(ResolvedDatabase {
path: canonical,
provenance: DatabaseProvenance::Canonical,
})
}
pub fn session_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
self.resolve_database(DatabaseSlot::Sessions)
}
pub fn continuity_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
self.resolve_database(DatabaseSlot::Continuity)
}
pub fn metadata_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
self.resolve_database(DatabaseSlot::Metadata)
}
pub fn console_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
self.resolve_database(DatabaseSlot::Console)
}
pub fn agent_memory_root(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
self.resolve_database(DatabaseSlot::AgentMemory)
}
pub fn runtime_db(&self) -> PathBuf {
self.state_dir.join(RUNTIME_DB_FILE_NAME)
}
pub fn schedule_db(&self) -> PathBuf {
self.state_dir.join(SCHEDULE_STORE_FILE)
}
pub fn workgraph_db(&self) -> PathBuf {
self.state_dir.join(WORKGRAPH_STORE_FILE)
}
pub fn jobs_db(&self) -> PathBuf {
meerkat_store::realm_paths_in(
&self.state_dir,
crate::storage_provider::MEERKAT_LEVEL_REALM_ID,
)
.jobs_sqlite_path
}
pub fn event_log_db(&self) -> PathBuf {
self.state_dir.join(EVENT_LOG_DB_FILE_NAME)
}
pub fn blob_root(&self) -> PathBuf {
self.state_dir.join(BLOB_ROOT_DIR_NAME)
}
pub fn peer_key_file(&self) -> Option<PathBuf> {
self.gateway_home
.as_ref()
.map(|home| home.join(KEY_FILE_NAME))
}
pub fn registry_file(&self) -> Option<PathBuf> {
self.gateway_home
.as_ref()
.map(|home| home.join(RUNTIME_REGISTRY_FILE_NAME))
}
pub fn layout_summary(&self) -> StorageLayoutSummary {
let databases = DatabaseSlot::ALL
.into_iter()
.map(|slot| {
let resolution = match self.resolve_database(slot) {
Ok(resolved) => DatabaseResolution::Resolved {
path: resolved.path,
provenance: resolved.provenance,
},
Err(StorageLayoutError::FileNameTwins { paths, .. }) => {
DatabaseResolution::Twins { paths }
}
Err(_) => unreachable!("resolve_database only fails on twins"),
};
DatabaseSummary { slot, resolution }
})
.collect();
StorageLayoutSummary {
state_dir: self.state_dir.clone(),
durability: self.durability,
gateway_home: self.gateway_home.clone(),
meerkat_state_root: self.meerkat_state_root.clone(),
blob_root: self.blob_root(),
peer_key_file: self.peer_key_file(),
registry_file: self.registry_file(),
databases,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DatabaseSummary {
pub slot: DatabaseSlot,
pub resolution: DatabaseResolution,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum DatabaseResolution {
Resolved {
path: PathBuf,
provenance: DatabaseProvenance,
},
Twins {
paths: Vec<PathBuf>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageLayoutSummary {
pub state_dir: PathBuf,
pub durability: StateDirDurability,
pub gateway_home: Option<PathBuf>,
pub meerkat_state_root: Option<PathBuf>,
pub blob_root: PathBuf,
pub peer_key_file: Option<PathBuf>,
pub registry_file: Option<PathBuf>,
pub databases: Vec<DatabaseSummary>,
}
pub fn default_gateway_home() -> Result<PathBuf, StorageLayoutError> {
gateway_home_from(
std::env::var("XDG_STATE_HOME").ok().as_deref(),
std::env::var("HOME").ok().as_deref(),
)
}
fn gateway_home_from(
xdg_state_home: Option<&str>,
home: Option<&str>,
) -> Result<PathBuf, StorageLayoutError> {
if let Some(xdg) = xdg_state_home
&& !xdg.trim().is_empty()
{
return Ok(PathBuf::from(xdg).join(GATEWAY_HOME_DIR_NAME));
}
let home = home.ok_or(StorageLayoutError::GatewayHomeUnavailable)?;
Ok(PathBuf::from(home)
.join(".local")
.join("state")
.join(GATEWAY_HOME_DIR_NAME))
}
pub fn default_ephemeral_scratch_root() -> PathBuf {
std::env::temp_dir().join(format!("mobkit-scratch-{}", std::process::id()))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::write(path, b"").expect("touch fixture file");
}
fn durable_layout(dir: &Path) -> MobKitStorageLayout {
MobKitStorageLayout::with_injected_roots(dir.to_path_buf(), None)
}
#[test]
fn fresh_dir_resolves_every_slot_to_canonical() {
let tmp = tempfile::tempdir().expect("tempdir");
let layout = durable_layout(tmp.path());
for slot in DatabaseSlot::ALL {
let resolved = layout.resolve_database(slot).expect("fresh dir resolves");
assert_eq!(resolved.provenance, DatabaseProvenance::Canonical);
assert_eq!(resolved.path, tmp.path().join(slot.canonical_name()));
}
assert_eq!(
layout.session_db().expect("sessions").path,
tmp.path().join("sessions.sqlite3")
);
assert_eq!(
layout.continuity_db().expect("continuity").path,
tmp.path().join("continuity.sqlite3")
);
}
#[test]
fn canonical_only_resolves_canonical() {
let tmp = tempfile::tempdir().expect("tempdir");
touch(&tmp.path().join("sessions.sqlite3"));
let resolved = durable_layout(tmp.path()).session_db().expect("resolve");
assert_eq!(resolved.provenance, DatabaseProvenance::Canonical);
assert_eq!(resolved.path, tmp.path().join("sessions.sqlite3"));
}
#[test]
fn legacy_only_resolves_where_it_lies() {
let tmp = tempfile::tempdir().expect("tempdir");
let layout = durable_layout(tmp.path());
for (slot, legacy) in [
(DatabaseSlot::Sessions, "sessions.db"),
(DatabaseSlot::Sessions, "sessions.sqlite"),
(DatabaseSlot::Continuity, "continuity.db"),
(DatabaseSlot::Continuity, "identity_continuity.sqlite"),
(DatabaseSlot::Metadata, "mobkit_metadata.sqlite"),
(DatabaseSlot::Console, "mobkit_console.sqlite"),
] {
let path = tmp.path().join(legacy);
touch(&path);
let resolved = layout.resolve_database(slot).expect("legacy resolves");
assert_eq!(
resolved.provenance,
DatabaseProvenance::LegacySpelling(legacy.to_string()),
"slot {slot} legacy {legacy}"
);
assert_eq!(resolved.path, path);
std::fs::remove_file(&path).expect("cleanup fixture");
}
let legacy_dir = tmp.path().join("agent-memory-sqlite");
std::fs::create_dir(&legacy_dir).expect("legacy memory dir");
let resolved = layout.agent_memory_root().expect("legacy dir resolves");
assert_eq!(
resolved.provenance,
DatabaseProvenance::LegacySpelling("agent-memory-sqlite".to_string())
);
assert_eq!(resolved.path, legacy_dir);
}
#[test]
fn canonical_plus_legacy_refuses_as_twins() {
let tmp = tempfile::tempdir().expect("tempdir");
touch(&tmp.path().join("sessions.sqlite3"));
touch(&tmp.path().join("sessions.db"));
let err = durable_layout(tmp.path())
.session_db()
.expect_err("twins refuse");
let StorageLayoutError::FileNameTwins { slot, paths } = err else {
panic!("expected FileNameTwins");
};
assert_eq!(slot, DatabaseSlot::Sessions);
assert_eq!(
paths,
vec![
tmp.path().join("sessions.sqlite3"),
tmp.path().join("sessions.db"),
]
);
}
#[test]
fn two_legacy_spellings_refuse_as_twins() {
let tmp = tempfile::tempdir().expect("tempdir");
touch(&tmp.path().join("continuity.db"));
touch(&tmp.path().join("identity_continuity.sqlite"));
let err = durable_layout(tmp.path())
.continuity_db()
.expect_err("legacy twins refuse");
let StorageLayoutError::FileNameTwins { slot, paths } = err else {
panic!("expected FileNameTwins");
};
assert_eq!(slot, DatabaseSlot::Continuity);
assert_eq!(paths.len(), 2);
assert!(err_to_string(&slot, &paths).contains("mobkit/storage/doctor"));
}
fn err_to_string(slot: &DatabaseSlot, paths: &[PathBuf]) -> String {
StorageLayoutError::FileNameTwins {
slot: *slot,
paths: paths.to_vec(),
}
.to_string()
}
#[test]
fn store_path_with_extension_is_an_explicit_session_override() {
let tmp = tempfile::tempdir().expect("tempdir");
let db_file = tmp.path().join("custom-sessions.db");
let layout = MobKitStorageLayout::standalone_from_store_path(
&db_file,
tmp.path().join("gateway-home"),
);
assert_eq!(layout.state_dir(), tmp.path());
assert_eq!(layout.session_db_override(), Some(db_file.as_path()));
touch(&tmp.path().join("sessions.sqlite3"));
touch(&tmp.path().join("sessions.db"));
let resolved = layout.session_db().expect("override resolves");
assert_eq!(resolved.provenance, DatabaseProvenance::ExplicitOverride);
assert_eq!(resolved.path, db_file);
assert!(layout.continuity_db().is_ok());
}
#[test]
fn store_path_without_extension_is_the_state_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("state");
let layout = MobKitStorageLayout::standalone_from_store_path(&dir, tmp.path().join("home"));
assert_eq!(layout.state_dir(), dir);
assert_eq!(layout.session_db_override(), None);
}
#[test]
fn gateway_home_prefers_non_blank_xdg_state_home() {
assert_eq!(
gateway_home_from(Some("/xdg/state"), Some("/home/user")).expect("xdg"),
PathBuf::from("/xdg/state").join("meerkat-mobkit")
);
assert_eq!(
gateway_home_from(Some(" "), Some("/home/user")).expect("blank xdg falls back"),
PathBuf::from("/home/user")
.join(".local")
.join("state")
.join("meerkat-mobkit")
);
assert_eq!(
gateway_home_from(None, Some("/home/user")).expect("home fallback"),
PathBuf::from("/home/user")
.join(".local")
.join("state")
.join("meerkat-mobkit")
);
assert_eq!(
gateway_home_from(None, None).expect_err("no roots"),
StorageLayoutError::GatewayHomeUnavailable
);
}
#[test]
fn embedded_layout_has_no_gateway_home_and_records_the_meerkat_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let meerkat = meerkat_core::StorageLayout::with_injected_roots(
tmp.path().to_path_buf(),
None,
None,
tmp.path().join("realm-root"),
);
let layout =
MobKitStorageLayout::from_meerkat_layout(&meerkat, tmp.path().join("mobkit-state"));
assert_eq!(layout.state_dir(), tmp.path().join("mobkit-state"));
assert_eq!(
layout.meerkat_state_root(),
Some(tmp.path().join("realm-root").as_path())
);
assert_eq!(layout.gateway_home(), None);
assert_eq!(layout.peer_key_file(), None);
assert_eq!(layout.registry_file(), None);
assert!(!layout.is_declared_ephemeral());
}
#[test]
fn standalone_layout_owns_the_gateway_home_files() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("gw-home");
let layout = MobKitStorageLayout::standalone(tmp.path().join("state"), home.clone());
assert_eq!(layout.gateway_home(), Some(home.as_path()));
assert_eq!(layout.peer_key_file(), Some(home.join("peer_key.ed25519")));
assert_eq!(layout.registry_file(), Some(home.join("tux-runtimes.json")));
}
#[test]
fn declared_ephemeral_layout_is_recorded_in_the_summary() {
let tmp = tempfile::tempdir().expect("tempdir");
let layout = MobKitStorageLayout::declared_ephemeral(tmp.path().join("scratch"));
assert!(layout.is_declared_ephemeral());
let summary = layout.layout_summary();
assert_eq!(summary.durability, StateDirDurability::DeclaredEphemeral);
assert_eq!(summary.state_dir, tmp.path().join("scratch"));
}
#[test]
fn layout_summary_round_trips_through_serde_and_reports_twins() {
let tmp = tempfile::tempdir().expect("tempdir");
touch(&tmp.path().join("sessions.sqlite3"));
touch(&tmp.path().join("sessions.db"));
touch(&tmp.path().join("mobkit_metadata.sqlite"));
let layout =
MobKitStorageLayout::standalone(tmp.path().to_path_buf(), tmp.path().join("home"));
let summary = layout.layout_summary();
let sessions = summary
.databases
.iter()
.find(|entry| entry.slot == DatabaseSlot::Sessions)
.expect("sessions entry");
assert!(matches!(
sessions.resolution,
DatabaseResolution::Twins { ref paths } if paths.len() == 2
));
let metadata = summary
.databases
.iter()
.find(|entry| entry.slot == DatabaseSlot::Metadata)
.expect("metadata entry");
assert!(matches!(
metadata.resolution,
DatabaseResolution::Resolved {
provenance: DatabaseProvenance::LegacySpelling(ref name),
..
} if name == "mobkit_metadata.sqlite"
));
let json = serde_json::to_string(&summary).expect("serialize summary");
let restored: StorageLayoutSummary =
serde_json::from_str(&json).expect("deserialize summary");
assert_eq!(restored, summary);
}
#[test]
fn fixed_name_slots_agree_with_the_shared_wiring_constants() {
let tmp = tempfile::tempdir().expect("tempdir");
let layout = durable_layout(tmp.path());
assert_eq!(layout.runtime_db(), tmp.path().join("runtime.sqlite"));
assert_eq!(
layout.schedule_db(),
tmp.path().join(crate::schedule_wiring::SCHEDULE_STORE_FILE)
);
assert_eq!(
layout.workgraph_db(),
tmp.path()
.join(crate::workgraph_wiring::WORKGRAPH_STORE_FILE)
);
assert_eq!(
layout.jobs_db(),
tmp.path()
.join(crate::storage_provider::MEERKAT_LEVEL_REALM_ID)
.join("jobs.sqlite3")
);
assert_eq!(layout.blob_root(), tmp.path().join("blobs"));
assert_eq!(layout.event_log_db(), tmp.path().join("event_log.sqlite3"));
}
}