use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::Arc;
use crate::cli::commands::export_reflections::{self, ExportFormat};
use crate::db;
use crate::storage::reflect::{ReflectHooks, ReflectOutcome};
#[cfg(any(test, debug_assertions))]
const AUTO_EXPORT_INJECT_PANIC_ENV: &str = "AI_MEMORY_AUTO_EXPORT_INJECT_PANIC";
#[derive(Debug, Clone)]
pub struct AutoExportConfig {
pub out_dir: PathBuf,
pub format: ExportFormat,
}
impl AutoExportConfig {
#[must_use]
pub fn default_for_home() -> Self {
let out_dir = export_reflections::resolve_out_dir(None).unwrap_or_else(|_| {
PathBuf::from(crate::AI_MEMORY_HOME_DIR_NAME)
.join(export_reflections::REFLECTIONS_SUBDIR)
});
Self {
out_dir,
format: ExportFormat::Markdown,
}
}
}
impl Default for AutoExportConfig {
fn default() -> Self {
Self::default_for_home()
}
}
#[must_use]
pub fn build_post_reflect_hook(
db_path: PathBuf,
config: AutoExportConfig,
) -> ReflectHooks<'static> {
let cfg = Arc::new(config);
let dbp = Arc::new(db_path);
let cb: Box<dyn Fn(&ReflectOutcome) + Send + Sync + 'static> = Box::new(move |outcome| {
let cfg = cfg.clone();
let dbp = dbp.clone();
let outcome_id = outcome.id.clone();
let namespace = outcome.namespace.clone();
std::thread::spawn(move || {
let outcome_id_for_log = outcome_id.clone();
let namespace_for_log = namespace.clone();
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
#[cfg(any(test, debug_assertions))]
{
if std::env::var(AUTO_EXPORT_INJECT_PANIC_ENV)
.ok()
.is_some_and(|v| v == "1")
{
panic!("auto_export panic injected via {AUTO_EXPORT_INJECT_PANIC_ENV}=1");
}
}
run_auto_export(&dbp, &outcome_id, &namespace, &cfg)
}));
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
crate::metrics::record_auto_export_spawn_failed();
tracing::warn!(
target: "post_reflect.auto_export",
"auto-export of reflection {} (ns={}) failed: {}",
outcome_id_for_log,
namespace_for_log,
e,
);
}
Err(panic_payload) => {
crate::metrics::record_auto_export_spawn_failed();
let panic_msg = panic_payload
.downcast_ref::<String>()
.cloned()
.or_else(|| {
panic_payload
.downcast_ref::<&'static str>()
.map(|s| (*s).to_string())
})
.unwrap_or_else(|| "<non-string panic payload>".to_string());
tracing::warn!(
target: "post_reflect.auto_export",
"auto-export of reflection {} (ns={}) panicked: {}",
outcome_id_for_log,
namespace_for_log,
panic_msg,
);
}
}
});
});
ReflectHooks {
pre_reflect: None,
post_reflect: Some(cb),
active_keypair: None,
}
}
pub fn run_auto_export(
db_path: &std::path::Path,
memory_id: &str,
namespace: &str,
config: &AutoExportConfig,
) -> anyhow::Result<()> {
let conn = db::open(db_path)?;
let policy = db::resolve_governance_policy(&conn, namespace).unwrap_or_default();
if !policy.effective_auto_export_reflections_to_filesystem() {
return Ok(());
}
let mem = match db::get(&conn, memory_id)? {
Some(m) => m,
None => {
return Ok(());
}
};
let edges = collect_outbound_reflects_on(&conn, memory_id)?;
let attest_level = export_reflections::summarise_attest_level(&edges);
let payload = export_reflections::render_payload(&mem, &edges, attest_level, config.format);
let ns_dir = config
.out_dir
.join(export_reflections::sanitise_namespace_for_path(
&mem.namespace,
));
std::fs::create_dir_all(&ns_dir)?;
let path = ns_dir.join(format!("{}.{}", mem.id, config.format.extension()));
std::fs::write(&path, payload)?;
Ok(())
}
fn collect_outbound_reflects_on(
conn: &rusqlite::Connection,
memory_id: &str,
) -> anyhow::Result<Vec<export_reflections::ReflectsOnEdge>> {
let mut stmt = conn.prepare(
"SELECT target_id, COALESCE(attest_level, 'unsigned'), created_at \
FROM memory_links \
WHERE source_id = ?1 AND relation = 'reflects_on' \
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(rusqlite::params![memory_id], |row| {
Ok(export_reflections::ReflectsOnEdge {
target_id: row.get(0)?,
attest_level: row.get(1)?,
created_at: row.get(2)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
ApproverType, CorePolicy, ExportPolicy, GovernanceLevel, GovernancePolicy, Memory, Tier,
};
use chrono::Utc;
use tempfile::TempDir;
fn fresh_db() -> (rusqlite::Connection, TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).unwrap();
(conn, dir, path)
}
fn seed_observation(conn: &rusqlite::Connection, ns: &str) -> String {
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: "obs".into(),
content: "obs body".into(),
created_at: now.clone(),
updated_at: now,
..Default::default()
};
db::insert(conn, &mem).unwrap()
}
fn enable_auto_export_on_namespace(conn: &rusqlite::Connection, ns: &str) {
let policy = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Human,
inherit: true,
max_reflection_depth: None,
},
export: ExportPolicy {
auto_export_reflections_to_filesystem: Some(true),
},
..Default::default()
};
let gov_metadata = serde_json::json!({
"agent_id": "ai:test",
"governance": serde_json::to_value(&policy).unwrap(),
});
let now = Utc::now().to_rfc3339();
let std_mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.to_string(),
title: format!("__standard_{ns}"),
content: "standard".into(),
created_at: now.clone(),
updated_at: now,
metadata: gov_metadata,
..Default::default()
};
let std_id = db::insert(conn, &std_mem).unwrap();
db::set_namespace_standard(conn, ns, &std_id, None).unwrap();
}
#[test]
fn run_auto_export_skips_when_policy_disabled() {
let (conn, dir, db_path) = fresh_db();
let src = seed_observation(&conn, "skip-ns");
let input = crate::storage::reflect::ReflectInput {
source_ids: vec![src.clone()],
title: "rfl".into(),
content: "rfl body".into(),
namespace: Some("skip-ns".into()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".into(),
agent_id: "ai:test".into(),
metadata: serde_json::json!({}),
};
let outcome = crate::storage::reflect::reflect(&conn, &input).unwrap();
let cfg = AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Markdown,
};
run_auto_export(&db_path, &outcome.id, &outcome.namespace, &cfg).unwrap();
assert!(
!dir.path().join("out").join("skip-ns").exists(),
"auto-export must not fire when policy is disabled"
);
}
#[test]
fn run_auto_export_writes_md_when_policy_enabled() {
let (conn, dir, db_path) = fresh_db();
enable_auto_export_on_namespace(&conn, "write-ns");
let src = seed_observation(&conn, "write-ns");
let input = crate::storage::reflect::ReflectInput {
source_ids: vec![src.clone()],
title: "rfl".into(),
content: "rfl body line".into(),
namespace: Some("write-ns".into()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".into(),
agent_id: "ai:test".into(),
metadata: serde_json::json!({}),
};
let outcome = crate::storage::reflect::reflect(&conn, &input).unwrap();
let cfg = AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Markdown,
};
run_auto_export(&db_path, &outcome.id, &outcome.namespace, &cfg).unwrap();
let f = dir
.path()
.join("out")
.join("write-ns")
.join(format!("{}.md", outcome.id));
assert!(f.exists(), "expected exported file at {}", f.display());
let body = std::fs::read_to_string(&f).unwrap();
assert!(body.contains(&format!("memory_id: {}\n", outcome.id)));
assert!(body.contains("namespace: write-ns\n"));
assert!(body.contains("reflection_depth: 1\n"));
assert!(body.contains("rfl body line"));
}
#[test]
fn run_auto_export_writes_json_when_format_json() {
let (conn, dir, db_path) = fresh_db();
enable_auto_export_on_namespace(&conn, "json-ns");
let src = seed_observation(&conn, "json-ns");
let input = crate::storage::reflect::ReflectInput {
source_ids: vec![src.clone()],
title: "rfl".into(),
content: "rfl json body".into(),
namespace: Some("json-ns".into()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".into(),
agent_id: "ai:test".into(),
metadata: serde_json::json!({}),
};
let outcome = crate::storage::reflect::reflect(&conn, &input).unwrap();
let cfg = AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Json,
};
run_auto_export(&db_path, &outcome.id, &outcome.namespace, &cfg).unwrap();
let f = dir
.path()
.join("out")
.join("json-ns")
.join(format!("{}.json", outcome.id));
assert!(f.exists());
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&f).unwrap()).unwrap();
assert_eq!(parsed["memory_id"].as_str().unwrap(), outcome.id);
}
#[test]
fn run_auto_export_swallows_missing_memory() {
let (_, dir, db_path) = fresh_db();
let cfg = AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Markdown,
};
let res = run_auto_export(&db_path, "no-such-id", "no-such-ns", &cfg);
assert!(res.is_ok());
}
#[test]
fn build_post_reflect_hook_does_not_block_reflect_response() {
let (conn, dir, db_path) = fresh_db();
enable_auto_export_on_namespace(&conn, "block-ns");
let src = seed_observation(&conn, "block-ns");
let hooks = build_post_reflect_hook(
db_path.clone(),
AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Markdown,
},
);
let input = crate::storage::reflect::ReflectInput {
source_ids: vec![src.clone()],
title: "rfl".into(),
content: "rfl body".into(),
namespace: Some("block-ns".into()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".into(),
agent_id: "ai:test".into(),
metadata: serde_json::json!({}),
};
let started = std::time::Instant::now();
let outcome = crate::storage::reflect::reflect_with_hooks(&conn, &input, &hooks).unwrap();
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"reflect_with_hooks should not block on auto-export disk write (took {elapsed:?})"
);
assert_eq!(outcome.namespace, "block-ns");
let _ = outcome.id;
}
#[test]
fn auto_export_worker_panic_increments_spawn_failed_counter() {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (conn, dir, db_path) = fresh_db();
enable_auto_export_on_namespace(&conn, "panic-ns");
let src = seed_observation(&conn, "panic-ns");
let hooks = build_post_reflect_hook(
db_path.clone(),
AutoExportConfig {
out_dir: dir.path().join("out"),
format: ExportFormat::Markdown,
},
);
let input = crate::storage::reflect::ReflectInput {
source_ids: vec![src.clone()],
title: "rfl".into(),
content: "rfl body".into(),
namespace: Some("panic-ns".into()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".into(),
agent_id: "ai:test".into(),
metadata: serde_json::json!({}),
};
let before = crate::metrics::auto_export_spawn_failed_count();
unsafe {
std::env::set_var(AUTO_EXPORT_INJECT_PANIC_ENV, "1");
}
let _outcome = crate::storage::reflect::reflect_with_hooks(&conn, &input, &hooks).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut after = before;
while std::time::Instant::now() < deadline {
after = crate::metrics::auto_export_spawn_failed_count();
if after > before {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
unsafe {
std::env::remove_var(AUTO_EXPORT_INJECT_PANIC_ENV);
}
assert!(
after > before,
"auto_export_spawn_failed_total did not advance after panic injection \
(before={before}, after={after})"
);
}
#[test]
fn auto_export_config_default_for_home_picks_dot_ai_memory() {
let cfg = AutoExportConfig::default_for_home();
assert!(
cfg.out_dir.ends_with("reflections"),
"default out_dir should end in 'reflections', got {}",
cfg.out_dir.display()
);
assert_eq!(cfg.format, ExportFormat::Markdown);
}
}