use std::path::Path;
use std::sync::Arc;
use anyhow::Context as _;
use crate::bootstrap::load_config_or_default;
use zeph_core::config::Config;
use zeph_core::durable::XChaCha20Poly1305Cipher;
use zeph_core::vault::AgeVaultProvider;
use zeph_durable::{ExecutionId, Journal, LocalBackend};
use crate::cli::DurableCommand;
const REVEAL_WARNING: &str =
"WARNING: --reveal decrypts and prints payload bytes in cleartext. Do not share this output.";
pub(crate) fn resolve_durable_db_url(config: &Config) -> String {
let main = config.memory.sqlite_path.as_str();
let Some(dir) = Path::new(main)
.parent()
.filter(|d| !d.as_os_str().is_empty())
else {
return "durable.db".to_owned();
};
let legacy = dir.join("durable.db");
if legacy.exists() {
return legacy.to_string_lossy().into_owned();
}
let file_name = Path::new(main).file_name().map_or_else(
|| "zeph.db".to_owned(),
|s| s.to_string_lossy().into_owned(),
);
dir.join(format!("{file_name}.durable.db"))
.to_string_lossy()
.into_owned()
}
fn fmt_ts(ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(ms).map_or_else(
|| ms.to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
)
}
fn load_durable_cipher() -> anyhow::Result<XChaCha20Poly1305Cipher> {
let dir = zeph_core::vault::default_vault_dir();
let provider = AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age"))
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let key = provider.get("ZEPH_DURABLE_KEY").ok_or_else(|| {
anyhow::anyhow!("ZEPH_DURABLE_KEY not found in vault; cannot --reveal payloads")
})?;
XChaCha20Poly1305Cipher::from_vault_b64(key)
.map_err(|e| anyhow::anyhow!("invalid ZEPH_DURABLE_KEY: {e}"))
}
pub(crate) fn load_write_cipher(
config: &Config,
) -> anyhow::Result<Option<Arc<dyn zeph_durable::PayloadCipher>>> {
if !config.durable.encrypt_payload {
return Ok(None);
}
let cipher = load_durable_cipher()?;
Ok(Some(Arc::new(cipher)))
}
async fn open_backend(config: &Config, reveal: bool) -> anyhow::Result<Option<LocalBackend>> {
let url = resolve_durable_db_url(config);
if url != ":memory:" && !Path::new(&url).exists() {
println!(
"No durable journal at {url}.\n\
Durable execution may be disabled; enable it with `[durable] enabled = true`."
);
return Ok(None);
}
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.map_err(|e| anyhow::anyhow!("failed to open durable journal: {e}"))?;
backend
.init()
.await
.map_err(|e| anyhow::anyhow!("failed to initialize durable schema: {e}"))?;
if reveal && config.durable.encrypt_payload {
let cipher = load_durable_cipher()?;
Ok(Some(backend.with_cipher(Arc::new(cipher))))
} else {
Ok(Some(backend))
}
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn handle_durable_command(
cmd: DurableCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let config_file = crate::bootstrap::resolve_config_path(config_path);
let config = load_config_or_default(&config_file);
match cmd {
DurableCommand::List {
status,
kind,
limit,
json,
} => {
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let rows = backend
.list_executions(status.as_deref(), kind.as_deref(), limit)
.await
.map_err(|e| anyhow::anyhow!("failed to list executions: {e}"))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&rows)
.context("failed to serialize execution list")?
);
} else {
if rows.is_empty() {
println!("No durable executions match.");
return Ok(());
}
println!(
"{:<36} {:<18} {:<10} {:>6} CREATED",
"EXECUTION ID", "KIND", "STATUS", "STEPS"
);
println!("{}", "-".repeat(96));
for row in &rows {
println!(
"{:<36} {:<18} {:<10} {:>6} {}",
row.execution_id.as_uuid(),
row.kind,
row.status.as_str(),
row.step_count,
fmt_ts(row.created_at_ms),
);
}
}
}
DurableCommand::Show { id, reveal, json } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, reveal).await? else {
return Ok(());
};
show_entries(&backend, exec, reveal, None, json).await?;
}
DurableCommand::Inspect {
id,
step,
reveal,
json,
} => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, reveal).await? else {
return Ok(());
};
show_entries(&backend, exec, reveal, Some(step), json).await?;
}
DurableCommand::Prune { dry_run } => {
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let policy = &config.durable.retention;
if dry_run {
let n = backend
.count_prunable(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to count prunable executions: {e}"))?;
println!("Dry run: {n} terminal execution(s) past TTL would be pruned.");
} else {
let n = backend
.prune(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to prune journal: {e}"))?;
println!("Pruned {n} execution(s).");
}
}
DurableCommand::Resume { id } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if entries.is_empty() {
println!("No journal entries found for execution {id}.");
return Ok(());
}
println!(
"Execution {id} has {} journaled step(s).\n\
Automatic resume is performed by the agent process for supported execution kinds; \
standalone CLI replay is not available in this build (durable adapters A1-A4).",
entries.len()
);
}
}
Ok(())
}
async fn show_entries(
backend: &LocalBackend,
exec: ExecutionId,
reveal: bool,
step: Option<u32>,
json: bool,
) -> anyhow::Result<()> {
if reveal {
println!("{REVEAL_WARNING}\n");
let mut entries = backend
.read_execution(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_revealed(&entries);
}
} else {
let mut entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&entries)
.context("failed to serialize journal entries")?
);
} else if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_redacted(&entries);
}
}
Ok(())
}
fn print_redacted(entries: &[zeph_durable::RedactedEntry]) {
if entries.is_empty() {
println!("No journal entries.");
return;
}
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} CREATED",
"SEQ", "STEP", "ENTRY KIND", "EFFECT CLASS", "BYTES", "IDEM KEY"
);
println!("{}", "-".repeat(96));
for e in entries {
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} {}",
e.seq,
e.step_id.value(),
e.entry_kind,
e.effect_class.as_deref().unwrap_or("-"),
e.payload_len,
e.idem_key_prefix.as_deref().unwrap_or("-"),
fmt_ts(e.created_at_ms),
);
}
}
fn print_revealed(entries: &[zeph_durable::JournalEntry]) {
use zeph_durable::EntryKind;
if entries.is_empty() {
println!("No journal entries.");
return;
}
for e in entries {
let step = e.step_id.value();
match &e.entry {
EntryKind::StepResult {
payload, effect, ..
} => {
println!(
"step {step} [step_result, {effect:?}] {} bytes:",
payload.len()
);
println!(" {}", String::from_utf8_lossy(payload));
}
other => {
println!("step {step} [{}] (no payload)", other.tag());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn durable_db_is_filename_namespaced_sibling_of_sqlite_path() {
let mut config = Config::default();
config.memory.sqlite_path = "/data/zeph/zeph.db".to_owned();
assert_eq!(
resolve_durable_db_url(&config),
"/data/zeph/zeph.db.durable.db"
);
}
#[test]
fn distinct_sqlite_stems_resolve_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("alpha.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir.path().join("beta.db").to_string_lossy().into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn same_stem_different_extension_resolves_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir
.path()
.join("zeph.sqlite")
.to_string_lossy()
.into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn preexisting_legacy_durable_db_is_preferred() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("durable.db"), []).unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
assert_eq!(
resolve_durable_db_url(&config),
dir.path().join("durable.db").to_string_lossy()
);
}
#[tokio::test]
async fn distinct_databases_do_not_collide_on_shared_directory() {
let dir = tempfile::tempdir().unwrap();
let sqlite_a = dir.path().join("alpha.db").to_string_lossy().into_owned();
let sqlite_b = dir.path().join("beta.db").to_string_lossy().into_owned();
let mut config_a = Config::default();
config_a.memory.sqlite_path = sqlite_a.clone();
let mut config_b = Config::default();
config_b.memory.sqlite_path = sqlite_b.clone();
let url_a = resolve_durable_db_url(&config_a);
let url_b = resolve_durable_db_url(&config_b);
assert_ne!(
url_a, url_b,
"the two databases must resolve to distinct journal files"
);
let backend_a = LocalBackend::open(&url_a, 1_000_000).await.unwrap();
backend_a.init().await.unwrap();
let backend_b = LocalBackend::open(&url_b, 1_000_000).await.unwrap();
backend_b.init().await.unwrap();
assert!(
Path::new(&url_a).exists() && Path::new(&url_b).exists(),
"both journal files must actually exist on disk"
);
assert_ne!(
std::fs::canonicalize(&url_a).unwrap(),
std::fs::canonicalize(&url_b).unwrap(),
"the two journal files must be genuinely distinct files, not the same file via aliasing"
);
let conv_one = 1u64.to_le_bytes();
let fold = |sqlite_path: &str| {
let mut payload = conv_one.to_vec();
payload.extend_from_slice(sqlite_path.as_bytes());
ExecutionId::derive(b"zeph.agent_turn.v1", &payload)
};
let exec_a = fold(&sqlite_a);
let exec_b = fold(&sqlite_b);
assert_ne!(exec_a, exec_b);
let resumed_a = backend_a
.open_execution(exec_a, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
let resumed_b = backend_b
.open_execution(exec_b, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
assert!(
!resumed_a && !resumed_b,
"each database's first conversation must open a fresh execution, not resume the other's"
);
let executions_a = backend_a.list_executions(None, None, 10).await.unwrap();
let executions_b = backend_b.list_executions(None, None, 10).await.unwrap();
assert_eq!(executions_a.len(), 1);
assert_eq!(executions_b.len(), 1);
assert_eq!(executions_a[0].execution_id, exec_a);
assert_eq!(executions_b[0].execution_id, exec_b);
}
#[test]
fn durable_db_bare_filename_when_path_has_no_parent() {
let mut config = Config::default();
config.memory.sqlite_path = "zeph.db".to_owned();
assert_eq!(resolve_durable_db_url(&config), "durable.db");
}
#[test]
fn fmt_ts_formats_epoch_millis_as_utc() {
assert_eq!(fmt_ts(0), "1970-01-01 00:00:00");
}
#[tokio::test]
async fn open_backend_reveal_succeeds_without_key_when_encrypt_payload_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = false;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let backend = open_backend(&config, true)
.await
.expect("--reveal must succeed without ZEPH_DURABLE_KEY when encrypt_payload=false");
assert!(backend.is_some());
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn open_backend_reveal_requires_key_when_encrypt_payload_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let result = open_backend(&config, true).await;
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"expected --reveal to fail without ZEPH_DURABLE_KEY when encrypt_payload=true"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn write_path_attaches_cipher_and_seals_payload_when_encrypt_payload_enabled() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
);
provider.save().unwrap();
let cipher = load_write_cipher(&config)
.expect("cipher load must succeed with a real vault key")
.expect("encrypt_payload=true must produce a cipher");
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(cipher);
backend.init().await.unwrap();
let exec = ExecutionId::new();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
let plaintext: &[u8] = br#"{"secret":"token-value"}"#;
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(plaintext),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
let (stored,): (Option<Vec<u8>>,) = zeph_db::query_as(zeph_db::sql!(
"SELECT payload FROM durable_journal WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(backend.pool())
.await
.unwrap();
let stored = stored.expect("payload present");
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_ne!(
stored.as_slice(),
plaintext,
"payload must not be stored verbatim when encrypt_payload=true"
);
assert!(
serde_json::from_slice::<serde_json::Value>(&stored).is_err(),
"sealed payload must not parse as plaintext JSON"
);
let entries = backend.read_execution(exec).await.unwrap();
match &entries[0].entry {
EntryKind::StepResult { payload, .. } => assert_eq!(payload.as_ref(), plaintext),
other => panic!("unexpected entry kind: {other:?}"),
}
}
}