use crate::audit_log::AuditLog;
use crate::log_store::{LogRecord, LogStore};
use axum::http::HeaderMap;
use microresolve::{MicroResolve, MicroResolveConfig};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::{broadcast, Notify};
#[derive(Clone, Debug)]
pub struct KeyName(pub String);
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct UiSettings {
#[serde(default = "default_namespace")]
pub selected_namespace_id: String,
#[serde(default)]
pub selected_domain: String,
#[serde(default = "default_threshold")]
pub threshold: f32,
#[serde(default = "default_languages")]
pub languages: Vec<String>,
#[serde(default = "default_review_skip_threshold")]
pub review_skip_threshold: f32,
#[serde(default)]
pub models: Vec<microresolve::NamespaceModel>,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
selected_namespace_id: "default".to_string(),
selected_domain: String::new(),
threshold: 0.3,
languages: vec!["en".to_string()],
review_skip_threshold: 0.0,
models: Vec::new(),
}
}
}
fn default_namespace() -> String {
"default".to_string()
}
fn default_threshold() -> f32 {
0.3
}
fn default_languages() -> Vec<String> {
vec!["en".to_string()]
}
fn default_review_skip_threshold() -> f32 {
0.0
}
#[derive(Clone, serde::Serialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StudioEvent {
ItemQueued {
id: u64,
query: String,
app_id: String,
},
LlmStarted {
id: u64,
query: String,
},
LlmDone {
id: u64,
correct: Vec<String>,
wrong: Vec<String>,
phrases_added: usize,
summary: String,
},
FixApplied {
id: u64,
phrases_added: usize,
phrases_replaced: usize,
version_before: u64,
version_after: u64,
},
Escalated {
id: u64,
reason: String,
},
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct ConnectedClient {
pub name: String,
pub namespaces: Vec<String>,
pub tick_interval_secs: u32,
pub library_version: Option<String>,
pub last_seen_ms: u64,
}
pub struct ServerState {
pub engine: MicroResolve,
pub data_dir: Option<String>,
pub git_remote: RwLock<Option<String>>,
pub log_store: Mutex<LogStore>,
pub audit_log: AuditLog,
pub http: reqwest::Client,
pub llm_key: Option<String>,
pub review_mode: RwLock<HashMap<String, String>>,
pub ui_settings: RwLock<UiSettings>,
pub event_tx: broadcast::Sender<StudioEvent>,
pub worker_notify: Arc<Notify>,
pub key_store: std::sync::RwLock<crate::key_store::KeyStore>,
pub connected_clients: RwLock<HashMap<String, ConnectedClient>>,
}
pub type AppState = Arc<ServerState>;
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub fn app_id_from_headers(headers: &HeaderMap) -> String {
headers
.get("X-Namespace-ID")
.and_then(|v| v.to_str().ok())
.unwrap_or("default")
.to_string()
}
pub fn ensure_app(state: &AppState, app_id: &str) {
let _ = state.engine.namespace(app_id);
}
pub fn load_ui_settings(data_dir: &str) -> UiSettings {
let path = format!("{}/_settings.json", data_dir);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_ui_settings(state: &ServerState) {
let Some(ref dir) = state.data_dir else {
return;
};
let settings = state.ui_settings.read().unwrap().clone();
if let Ok(json) = serde_json::to_string_pretty(&settings) {
let _ = std::fs::write(format!("{}/_settings.json", dir), json);
}
}
pub fn maybe_commit(state: &ServerState, app_id: &str) {
if let Some(h) = state.engine.try_namespace(app_id) {
if let Err(e) = h.flush() {
eprintln!("flush error for {}: {}", app_id, e);
}
}
if let Some(ref dir) = state.data_dir {
git_commit(
dir,
&format!("update {}", app_id),
state.git_remote.read().unwrap().is_some(),
);
}
}
pub fn git_commit(data_dir: &str, message: &str, push: bool) {
if !std::path::Path::new(&format!("{}/.git", data_dir)).exists() {
return;
}
let dir = data_dir.to_string();
let msg = message.to_string();
tokio::spawn(async move {
let _ = tokio::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&dir)
.output()
.await;
let commit_out = tokio::process::Command::new("git")
.args(["commit", "--quiet", "-m", &msg])
.current_dir(&dir)
.output()
.await;
if push
&& commit_out
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false)
{
let push_out = tokio::process::Command::new("git")
.args(["push", "--quiet", "--set-upstream", "origin", "HEAD"])
.current_dir(&dir)
.output()
.await;
if let Ok(o) = push_out {
if !o.status.success() {
eprintln!(
"[data_git] push failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
}
});
}
pub fn build_engine(data_dir: Option<&str>) -> MicroResolve {
let config = MicroResolveConfig {
data_dir: data_dir.map(std::path::PathBuf::from),
..Default::default()
};
let engine = MicroResolve::new(config).expect("failed to initialise engine");
let _ = engine.namespace("default");
engine
}
pub fn log_query(state: &ServerState, record: LogRecord) -> u64 {
state
.log_store
.lock()
.map(|mut s| s.append(record))
.unwrap_or(0)
}
pub fn audit_mutation(
state: &ServerState,
kid: &str,
ns: &str,
event_type: &str,
payload: serde_json::Value,
) {
if !state.audit_log.mode().enabled() {
return;
}
state.audit_log.record(kid, ns, event_type, payload);
}
pub fn get_ns_mode(state: &ServerState, app_id: &str) -> String {
state
.review_mode
.read()
.unwrap()
.get(app_id)
.cloned()
.unwrap_or_else(|| "manual".to_string())
}
pub fn default_lang() -> String {
"en".to_string()
}