use std::sync::Arc;
use ijima_core::{IjimaError, Result, Store};
use crate::IjimaAuth;
use crate::api;
use crate::key_store;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub host: String,
pub port: u16,
}
fn autocapture_cutoff(now_secs: u64, ttl_days: u64) -> String {
(now_secs.saturating_sub(ttl_days.saturating_mul(86_400))).to_string()
}
fn spawn_autocapture_sweeper(store: Arc<dyn Store>, ttl_days: u64) {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
loop {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
match store
.delete_aged_autocapture(&autocapture_cutoff(now, ttl_days))
.await
{
Ok(0) => tracing::debug!("autocapture ttl sweep: nothing eligible"),
Ok(n) => tracing::info!(deleted = n, ttl_days, "autocapture ttl sweep"),
Err(e) => tracing::error!(error = %e, "autocapture ttl sweep failed"),
}
tokio::time::sleep(std::time::Duration::from_secs(86_400)).await;
}
});
}
pub fn init_tracing() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_env("IJIMA_LOG").unwrap_or_else(|_| EnvFilter::new("ijima=info")),
)
.try_init();
}
impl Default for DaemonConfig {
fn default() -> Self {
let file = crate::config::load().unwrap_or_default();
Self {
host: crate::config::resolve_str("IJIMA_HOST", file.host, "127.0.0.1"),
port: std::env::var("IJIMA_PORT")
.ok()
.and_then(|p| p.parse().ok())
.or(file.port)
.unwrap_or(7373),
}
}
}
#[cfg(feature = "federation")]
fn federation_config_from_env() -> ijima_core::federation::InstanceFederationConfig {
use ijima_core::federation::{
AuthoritativeScope, InstanceFederationConfig, InstanceId, InstanceRole,
};
use std::str::FromStr;
let instance_id = std::env::var("IJIMA_INSTANCE_ID")
.map(InstanceId::new)
.unwrap_or_default();
let role = std::env::var("IJIMA_INSTANCE_ROLE")
.ok()
.and_then(|r| InstanceRole::from_str(&r).ok())
.unwrap_or(InstanceRole::Unifying);
let authoritative_scopes = std::env::var("IJIMA_INSTANCE_SCOPES")
.ok()
.map(|s| {
s.split(',')
.filter_map(|p| AuthoritativeScope::from_str(p.trim()).ok())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![AuthoritativeScope::new("local", "*")]);
let capability_policy_ref = std::env::var("IJIMA_CAPABILITY_POLICY_REF")
.ok()
.filter(|s| !s.is_empty());
InstanceFederationConfig {
instance_id,
role,
authoritative_scopes,
outbound_links: Vec::new(),
capability_policy_ref,
}
}
pub async fn serve(config: &DaemonConfig) -> Result<()> {
init_tracing();
let file_config = crate::config::load()?;
let key_path = key_store::default_key_path()?;
let seed = key_store::load_or_create(&key_path)?;
let auth = Arc::new(IjimaAuth::from_embedded_policy_with_seed(seed)?);
#[cfg(feature = "embeddings-candle")]
let embedder: Option<Arc<dyn ijima_core::Embedder>> = {
let model = crate::config::resolve_str(
"IJIMA_EMBED_MODEL",
file_config.embedding_model.clone(),
crate::embeddings_candle::DEFAULT_MODEL,
);
let revision = std::env::var("IJIMA_EMBED_REVISION").unwrap_or_else(|_| "main".into());
let e: Arc<dyn ijima_core::Embedder> = Arc::new(
crate::embeddings_candle::CandleEmbedder::from_hub_model(&model, &revision)?,
);
tracing::info!(model = %e.model_id(), "embedder loaded");
Some(e)
};
#[cfg(not(feature = "embeddings-candle"))]
let embedder: Option<Arc<dyn ijima_core::Embedder>> = None;
let data_dir = crate::config::resolve_data_dir()?;
let db_path = data_dir.join("ijima.db");
#[cfg(feature = "embeddings-candle")]
let store_inner = Arc::new(
crate::SurrealStore::open_persistent_with(&db_path, embedder.clone().unwrap()).await?,
);
#[cfg(not(feature = "embeddings-candle"))]
let store_inner = Arc::new(crate::SurrealStore::open_persistent(&db_path).await?);
let store: Arc<dyn Store> = store_inner.clone();
let ttl_days = crate::config::resolve_u32(
"IJIMA_AUTOCAPTURE_TTL_DAYS",
file_config.autocapture_ttl_days,
30,
) as u64;
if ttl_days > 0 {
spawn_autocapture_sweeper(store.clone(), ttl_days);
tracing::info!(ttl_days, "autocapture ttl sweeper armed");
} else {
tracing::info!("autocapture ttl sweeper disabled (ttl=0)");
}
let kg: Arc<dyn ijima_core::KnowledgeGraph> = store_inner;
let revocations = store.list_revocations().await?;
if !revocations.is_empty() {
tracing::info!(count = revocations.len(), "hydrated token revocations");
}
auth.hydrate_revocations(&revocations);
#[cfg(feature = "rate-limit")]
let rate_limiter: Option<crate::rate_limit::RateLimitState> =
if std::env::var_os("IJIMA_RATE_DISABLE").is_some() {
None
} else {
let base = crate::config::resolve_f64("IJIMA_RATE_BASE", file_config.rate_base, 10.0);
let mult = crate::config::resolve_f64(
"IJIMA_RATE_MULTIPLIER",
file_config.rate_multiplier,
1.0,
);
tracing::info!(
base_tokens_per_second = base,
multiplier = mult,
"rate limiting enabled (Schubert intersection-number capacity)"
);
Some(crate::rate_limit::make_rate_limiter(base, mult))
};
let app = api::app(
auth,
store,
kg,
embedder,
std::sync::Arc::new(crate::redaction::Redactor::new()),
#[cfg(feature = "rate-limit")]
rate_limiter,
#[cfg(feature = "federation")]
std::sync::Arc::new(federation_config_from_env()),
);
let addr = format!("{}:{}", config.host, config.port);
#[cfg(feature = "tls")]
if let (Some(cert_path), Some(key_path)) = (
std::env::var_os("IJIMA_TLS_CERT"),
std::env::var_os("IJIMA_TLS_KEY"),
) {
let tls_config =
axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
.await
.map_err(|e| IjimaError::Store {
detail: format!("tls config: {e}"),
})?;
let socket_addr: std::net::SocketAddr = addr.parse().map_err(|e| IjimaError::Store {
detail: format!("parse {addr}: {e}"),
})?;
let listener = axum_server::bind_rustls(socket_addr, tls_config);
eprintln!("ijima: listening on https://{addr}");
listener
.serve(app.into_make_service())
.await
.map_err(|e| IjimaError::Store {
detail: format!("serve: {e}"),
})?;
return Ok(());
}
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| IjimaError::Store {
detail: format!("bind {addr}: {e}"),
})?;
tracing::info!(addr = %addr, "ijima listening");
axum::serve(listener, app)
.await
.map_err(|e| IjimaError::Store {
detail: format!("serve: {e}"),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::autocapture_cutoff;
#[test]
fn cutoff_subtracts_ttl_days() {
assert_eq!(
autocapture_cutoff(1_000_000_000, 30),
"997_408_000".replace('_', "")
);
assert_eq!(autocapture_cutoff(1_000_000_000, 0), "1000000000");
assert_eq!(autocapture_cutoff(10, 30), "0");
}
}