use std::path::PathBuf;
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::{Deserialize, Serialize};
use crate::api::error::AppError;
use crate::api::server::AppState;
pub(crate) async fn handle_health(State(_state): State<Arc<AppState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
}))
}
pub(crate) async fn handle_readiness(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (axum::http::StatusCode, Json<serde_json::Value>)> {
let mut components = serde_json::Map::new();
let mut all_healthy = true;
let ws_path = state.kernel.state.workspace_path();
let state_ok = ws_path.exists();
components.insert(
"state_store".into(),
serde_json::json!({"healthy": state_ok}),
);
all_healthy &= state_ok;
let git_ok = state.kernel.infra.git_verify().unwrap_or(false);
components.insert("git".into(), serde_json::json!({"healthy": git_ok}));
let (index_size, total) = state.kernel.agents.memory_stats().await;
components.insert(
"memory".into(),
serde_json::json!({"healthy": true, "index_size": index_size, "total_entries": total}),
);
let status = if all_healthy { "healthy" } else { "degraded" };
let body = serde_json::json!({
"status": status,
"version": env!("CARGO_PKG_VERSION"),
"uptime_secs": state.start_time.elapsed().as_secs(),
"components": components,
});
if all_healthy {
Ok(Json(body))
} else {
Err((axum::http::StatusCode::SERVICE_UNAVAILABLE, Json(body)))
}
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct ComponentStatus {
pub healthy: bool,
pub detail: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct MemoryHealth {
pub enabled: bool,
pub index_size: usize,
pub total_entries: usize,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct AgentHealth {
pub active_count: usize,
pub total_forked: u64,
pub total_completed: u64,
pub total_failed: u64,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct ComponentHealth {
pub state_store: ComponentStatus,
pub event_bus: ComponentStatus,
pub memory: MemoryHealth,
pub agents: AgentHealth,
pub spaces_active: usize,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct StatusResponse {
service: String,
status: String,
version: String,
web_version: String,
auth_enabled: bool,
channels: Vec<String>,
uptime: String,
components: Option<ComponentHealth>,
}
fn read_web_version(web_dist: &Option<PathBuf>) -> String {
#[derive(Deserialize)]
struct VersionFile {
version: Option<String>,
}
web_dist
.as_ref()
.and_then(|p| std::fs::read(p.join("version.json")).ok())
.and_then(|b| serde_json::from_slice::<VersionFile>(&b).ok())
.and_then(|v| v.version)
.unwrap_or_else(|| "dev".to_string())
}
pub(crate) async fn handle_status(state: State<Arc<AppState>>) -> Json<StatusResponse> {
let uptime = state.start_time.elapsed();
let uptime_str = format!(
"{}h {}m {}s",
uptime.as_secs() / 3600,
(uptime.as_secs() % 3600) / 60,
uptime.as_secs() % 60
);
let state_store_healthy = state.kernel.state.workspace_path().exists();
let event_bus_healthy = true;
let (mem_index_size, mem_total) = state.kernel.agents.memory_stats().await;
let memory_health = MemoryHealth {
enabled: true,
index_size: mem_index_size,
total_entries: mem_total,
};
let active_count = state
.kernel
.agents
.list()
.await
.map(|agents| {
agents
.iter()
.filter(|a| {
matches!(
a.status,
oxios_kernel::AgentStatus::Running
| oxios_kernel::AgentStatus::Starting
| oxios_kernel::AgentStatus::Idle
)
})
.count()
})
.unwrap_or(0);
let (total_forked, total_completed, total_failed) = parse_agent_metrics();
let agent_health = AgentHealth {
active_count,
total_forked,
total_completed,
total_failed,
};
let components = Some(ComponentHealth {
state_store: ComponentStatus {
healthy: state_store_healthy,
detail: if state_store_healthy {
None
} else {
Some("base path not found".to_string())
},
},
event_bus: ComponentStatus {
healthy: event_bus_healthy,
detail: None,
},
memory: memory_health,
agents: agent_health,
spaces_active: state
.kernel
.projects
.as_ref()
.map(|p| p.list_projects().len())
.unwrap_or(0),
});
let web_version = read_web_version(&state.web_dist.path());
Json(StatusResponse {
service: "oxios".into(),
status: "running".into(),
version: env!("CARGO_PKG_VERSION").into(),
web_version,
auth_enabled: state.config.read().security.auth_enabled,
channels: vec!["web".into()],
uptime: uptime_str,
components,
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateCheckParams {
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct UpdateCheckResponse {
pub current_version: String,
pub latest_version: String,
pub update_available: bool,
pub tag_name: String,
pub html_url: String,
pub release_notes: String,
pub published_at: String,
pub assets: Vec<AssetInfo>,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct AssetInfo {
pub name: String,
pub size: u64,
pub download_url: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateRunBody {
#[serde(default = "default_true")]
pub binary: bool,
#[serde(default = "default_true")]
pub web: bool,
pub version: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct UpdateRunResponse {
pub success: bool,
pub updated_to: String,
pub binary_updated: bool,
pub web_updated: bool,
pub message: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct ChangelogResponse {
pub tag_name: String,
pub version: String,
pub published_at: String,
pub body: String,
pub html_url: String,
}
pub(crate) async fn handle_update_check(
Query(params): Query<UpdateCheckParams>,
) -> Result<Json<UpdateCheckResponse>, AppError> {
let current = env!("CARGO_PKG_VERSION");
let release = fetch_github_release(params.version.as_deref()).await?;
let tag_name = release["tag_name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let latest_version = tag_name.trim_start_matches('v').to_string();
let html_url = release["html_url"].as_str().unwrap_or("").to_string();
let body_text = release["body"]
.as_str()
.unwrap_or("No release notes.")
.to_string();
let published_at = release["published_at"].as_str().unwrap_or("").to_string();
let assets: Vec<AssetInfo> = release["assets"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|a| {
Some(AssetInfo {
name: a["name"].as_str()?.to_string(),
size: a["size"].as_u64()?,
download_url: a["browser_download_url"].as_str()?.to_string(),
})
})
.collect();
Ok(Json(UpdateCheckResponse {
current_version: current.to_string(),
latest_version: latest_version.clone(),
update_available: latest_version != current,
tag_name,
html_url,
release_notes: body_text,
published_at,
assets,
}))
}
fn validate_update_version(v: &str) -> Result<(), AppError> {
if v.is_empty() || v.len() > 64 {
return Err(AppError::BadRequest(
"version must be 1..64 characters".into(),
));
}
let ok = v
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+' || c == '_');
if !ok {
return Err(AppError::BadRequest(
"version contains invalid characters (allowed: alphanumeric, . - + _)".into(),
));
}
Ok(())
}
const MAX_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024;
pub(crate) async fn handle_update_run(
state: State<Arc<AppState>>,
Json(body): Json<UpdateRunBody>,
) -> Result<Json<UpdateRunResponse>, AppError> {
let current = env!("CARGO_PKG_VERSION");
if let Some(ref v) = body.version {
validate_update_version(v)?;
}
let release = fetch_github_release(body.version.as_deref()).await?;
let tag_name = release["tag_name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let target_version = tag_name.trim_start_matches('v').to_string();
let assets: Vec<(String, String, u64)> = release["assets"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|a| {
Some((
a["name"].as_str()?.to_string(),
a["browser_download_url"].as_str()?.to_string(),
a["size"].as_u64()?,
))
})
.collect();
let client = reqwest::Client::builder()
.user_agent(format!("oxios/{current}"))
.build()
.map_err(|e| AppError::Internal(format!("failed to create HTTP client: {e}")))?;
let mut binary_updated = false;
let mut web_updated = false;
let mut messages: Vec<String> = Vec::new();
if body.web {
if let Some((name, url, size)) = assets.iter().find(|(n, _, _)| n == "web-dist.zip") {
tracing::info!(name, size, "Downloading web UI for update");
let bytes = download_bytes(&client, url, MAX_DOWNLOAD_BYTES).await?;
let web_root = dirs::home_dir()
.ok_or_else(|| AppError::Internal("cannot determine home directory".into()))?
.join(".oxios")
.join("web");
let staging = web_root.join(format!("dist-{target_version}"));
if staging.exists() {
std::fs::remove_dir_all(&staging)
.map_err(|e| AppError::Internal(format!("failed to clear staging: {e}")))?;
}
std::fs::create_dir_all(&staging)
.map_err(|e| AppError::Internal(format!("failed to create staging: {e}")))?;
let cursor = std::io::Cursor::new(&bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| AppError::Internal(format!("invalid zip: {e}")))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| AppError::Internal(format!("zip read error: {e}")))?;
let out_path = match file.enclosed_name() {
Some(p) => staging.join(p),
None => continue,
};
if file.is_dir() {
std::fs::create_dir_all(&out_path).ok();
} else {
if let Some(p) = out_path.parent() {
std::fs::create_dir_all(p).ok();
}
let mut out_file = std::fs::File::create(&out_path)
.map_err(|e| AppError::Internal(format!("write error: {e}")))?;
std::io::copy(&mut file, &mut out_file)
.map_err(|e| AppError::Internal(format!("write error: {e}")))?;
}
}
if !staging.join("index.html").is_file() {
return Err(AppError::Internal(
"extracted dist missing index.html".into(),
));
}
let marker = web_root.join(".active");
state.web_dist.publish(staging, &marker);
web_updated = true;
messages.push(format!("Web UI updated to {target_version}"));
} else {
messages.push("web-dist.zip not found in release, skipped".to_string());
}
}
if body.binary {
let mut args = vec!["install", "oxios", "locked"];
if let Some(ref v) = body.version {
args.push("--version");
args.push(v.as_str());
}
tracing::info!(?args, "Running cargo install for binary update");
let output = tokio::process::Command::new("cargo")
.args(&args)
.output()
.await
.map_err(|e| AppError::Internal(format!("failed to run cargo: {e}")))?;
if output.status.success() {
binary_updated = true;
messages.push(format!("Binary updated to {target_version} via cargo"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!(%stderr, "cargo install failed");
messages.push(format!(
"Binary update failed: {}",
stderr.lines().take(3).collect::<Vec<_>>().join("; ")
));
}
}
tracing::info!(
binary_updated,
web_updated,
target_version,
"Update completed"
);
Ok(Json(UpdateRunResponse {
success: true,
updated_to: target_version,
binary_updated,
web_updated,
message: messages.join("; "),
}))
}
pub(crate) async fn handle_update_changelog(
Query(params): Query<UpdateCheckParams>,
) -> Result<Json<ChangelogResponse>, AppError> {
let release = fetch_github_release(params.version.as_deref()).await?;
let tag_name = release["tag_name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let version = tag_name.trim_start_matches('v').to_string();
let published_at = release["published_at"].as_str().unwrap_or("").to_string();
let body = release["body"]
.as_str()
.unwrap_or("No release notes.")
.to_string();
let html_url = release["html_url"].as_str().unwrap_or("").to_string();
Ok(Json(ChangelogResponse {
tag_name,
version,
published_at,
body,
html_url,
}))
}
const GITHUB_OWNER: &str = "a7garden";
const GITHUB_REPO: &str = "oxios";
async fn fetch_github_release(version: Option<&str>) -> Result<serde_json::Value, AppError> {
let api_url = match version {
Some(v) => {
format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/tags/v{v}")
}
None => {
format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest")
}
};
let client = reqwest::Client::builder()
.user_agent(format!("oxios/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| AppError::Internal(format!("HTTP client error: {e}")))?;
let resp = client
.get(&api_url)
.send()
.await
.map_err(|e| AppError::Internal(format!("GitHub API request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Internal(format!(
"GitHub API error {status}: {body}"
)));
}
resp.json()
.await
.map_err(|e| AppError::Internal(format!("Failed to parse GitHub response: {e}")))
}
async fn download_bytes(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
) -> Result<Vec<u8>, AppError> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Download request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(AppError::Internal(format!("Download failed: {status}")));
}
if let Some(cl) = resp.content_length()
&& cl > max_bytes
{
return Err(AppError::PayloadTooLarge {
size: cl as usize,
limit: max_bytes as usize,
});
}
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|e| AppError::Internal(format!("Failed to read download body: {e}")))?;
let new_len = buf.len().saturating_add(chunk.len());
if new_len > max_bytes as usize {
return Err(AppError::PayloadTooLarge {
size: new_len,
limit: max_bytes as usize,
});
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
fn parse_agent_metrics() -> (u64, u64, u64) {
let export = oxios_kernel::metrics::registry().export();
let mut forked = 0u64;
let mut completed = 0u64;
let mut failed = 0u64;
for line in export.lines() {
if line.starts_with("oxios_agents_forked_total ") {
forked = line
.rsplit(' ')
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
} else if line.starts_with("oxios_agents_completed_total ") {
completed = line
.rsplit(' ')
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
} else if line.starts_with("oxios_agents_failed_total ") {
failed = line
.rsplit(' ')
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
}
}
(forked, completed, failed)
}
#[derive(Debug, Deserialize)]
pub(crate) struct AgentQueryParams {
pub q: Option<String>,
pub search_field: Option<String>,
pub status: Option<String>,
pub session_id: Option<String>,
pub project_id: Option<String>,
pub seed_id: Option<String>,
pub model_id: Option<String>,
pub tool: Option<String>,
pub has_error: Option<bool>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub cost_min: Option<f64>,
pub cost_max: Option<f64>,
pub tokens_min: Option<u64>,
pub tokens_max: Option<u64>,
pub duration_min: Option<u64>,
pub duration_max: Option<u64>,
pub sort_by: Option<String>,
pub sort_dir: Option<String>,
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_limit")]
pub per_page: u32,
}
fn default_page() -> u32 {
1
}
fn default_limit() -> u32 {
50
}
fn params_to_filter(p: &AgentQueryParams) -> oxios_kernel::agent_log_db::AgentListFilter {
let status = p.status.as_deref().and_then(|s| match s {
"running" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Running),
"completed" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Completed),
"failed" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Failed),
"stopped" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Stopped),
"starting" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Starting),
"idle" => Some(oxios_kernel::agent_log_db::AgentStatusFilter::Idle),
_ => None,
});
let search_field = p.search_field.as_deref().map_or(
oxios_kernel::agent_log_db::SearchField::All,
|s| match s {
"name" => oxios_kernel::agent_log_db::SearchField::Name,
"error" => oxios_kernel::agent_log_db::SearchField::Error,
"tool_name" => oxios_kernel::agent_log_db::SearchField::ToolName,
"tool_output" => oxios_kernel::agent_log_db::SearchField::ToolOutput,
_ => oxios_kernel::agent_log_db::SearchField::All,
},
);
let sort_by = p
.sort_by
.as_deref()
.map_or(oxios_kernel::agent_log_db::SortBy::CreatedAt, |s| match s {
"cost" => oxios_kernel::agent_log_db::SortBy::Cost,
"duration" => oxios_kernel::agent_log_db::SortBy::Duration,
"tokens" => oxios_kernel::agent_log_db::SortBy::Tokens,
"name" => oxios_kernel::agent_log_db::SortBy::Name,
_ => oxios_kernel::agent_log_db::SortBy::CreatedAt,
});
let sort_dir = p
.sort_dir
.as_deref()
.map_or(oxios_kernel::agent_log_db::SortDir::Desc, |s| match s {
"asc" => oxios_kernel::agent_log_db::SortDir::Asc,
_ => oxios_kernel::agent_log_db::SortDir::Desc,
});
let parse_dt = |s: &Option<String>| -> Option<chrono::DateTime<chrono::Utc>> {
s.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
};
oxios_kernel::agent_log_db::AgentListFilter {
q: p.q.clone(),
search_field,
status,
session_id: p.session_id.clone(),
project_id: p.project_id.clone(),
seed_id: p.seed_id.clone(),
model_id: p.model_id.clone(),
tool: p.tool.clone(),
has_error: p.has_error,
date_from: parse_dt(&p.date_from),
date_to: parse_dt(&p.date_to),
cost_min: p.cost_min,
cost_max: p.cost_max,
tokens_min: p.tokens_min,
tokens_max: p.tokens_max,
duration_min: p.duration_min,
duration_max: p.duration_max,
sort_by,
sort_dir,
page: p.page,
per_page: p.per_page,
}
}
pub(crate) async fn handle_agents_list(
state: State<Arc<AppState>>,
Query(params): Query<AgentQueryParams>,
) -> Json<serde_json::Value> {
let filter = params_to_filter(¶ms);
match state.kernel.agents.query(&filter).await {
Ok(result) => Json(serde_json::json!({
"items": result.items.iter().map(serialize_agent_summary).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"total_pages": result.total_pages,
"stats": {
"total_cost_usd": result.stats.total_cost_usd,
"total_tokens": result.stats.total_tokens,
"avg_duration_secs": result.stats.avg_duration_secs,
"count_running": result.stats.count_running,
"count_completed": result.stats.count_completed,
"count_failed": result.stats.count_failed,
},
})),
Err(e) => {
tracing::error!(error = %e, "Failed to query agents");
Json(serde_json::json!({
"items": [],
"total": 0,
"page": params.page,
"per_page": params.per_page,
"total_pages": 0,
"stats": {},
}))
}
}
}
pub(crate) async fn handle_agent_stats(state: State<Arc<AppState>>) -> Json<serde_json::Value> {
match state.kernel.agents.stats().await {
Ok(s) => Json(serde_json::json!({
"total_agents": s.total_agents,
"running": s.running,
"completed": s.completed,
"failed": s.failed,
"total_cost_usd": s.total_cost_usd,
"total_tokens": s.total_tokens,
"total_duration_secs": s.total_duration_secs,
"avg_duration_secs": s.avg_duration_secs,
"avg_cost_usd": s.avg_cost_usd,
"total_sessions": s.total_sessions,
"oldest_agent_at": s.oldest_agent_at.map(|t| t.to_rfc3339()),
"newest_agent_at": s.newest_agent_at.map(|t| t.to_rfc3339()),
})),
Err(e) => {
tracing::error!(error = %e, "Failed to get agent stats");
Json(serde_json::json!({"error": e.to_string()}))
}
}
}
fn serialize_agent_summary(a: &oxios_kernel::types::AgentInfo) -> serde_json::Value {
serde_json::json!({
"id": a.id.to_string(),
"name": a.name,
"status": a.status.to_string(),
"created_at": a.created_at.to_rfc3339(),
"started_at": a.started_at.map(|t| t.to_rfc3339()),
"completed_at": a.completed_at.map(|t| t.to_rfc3339()),
"seed_id": a.seed_id.map(|s| s.to_string()),
"project_id": a.project_id.map(|id| id.to_string()),
"session_id": a.session_id,
"error": a.error,
"steps_completed": a.steps_completed,
"steps_total": a.steps_total,
"tokens_used": a.tokens_input + a.tokens_output,
"cost_usd": a.cost_usd,
"model_id": a.model_id,
"duration_secs": a.completed_at.zip(a.started_at)
.map(|(end, start)| (end - start).num_seconds().max(0)),
})
}
pub(crate) async fn handle_agent_get(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
if let Ok(agents) = state.kernel.agents.list().await
&& let Some(agent) = agents.into_iter().find(|a| a.id.to_string() == id)
{
return Ok(Json(agent_detail_json(&agent, &state)));
}
let agent = state.kernel.agents.get(&id).await?;
match agent {
Some(agent) => Ok(Json(agent_detail_json(&agent, &state))),
None => Err(AppError::NotFound("agent not found".into())),
}
}
fn agent_detail_json(
agent: &oxios_kernel::types::AgentInfo,
state: &Arc<AppState>,
) -> serde_json::Value {
let budget = state.kernel.agents.check_budget(&agent.id);
serde_json::json!({
"id": agent.id.to_string(),
"name": agent.name,
"status": agent.status.to_string(),
"created_at": agent.created_at.to_rfc3339(),
"seed_id": agent.seed_id.map(|s| s.to_string()),
"project_id": agent.project_id.map(|id| id.to_string()),
"session_id": agent.session_id,
"started_at": agent.started_at.map(|t| t.to_rfc3339()),
"completed_at": agent.completed_at.map(|t| t.to_rfc3339()),
"error": agent.error,
"steps_completed": agent.steps_completed,
"steps_total": agent.steps_total,
"tokens_used": agent.tokens_input + agent.tokens_output,
"cost_usd": agent.cost_usd,
"model_id": agent.model_id,
"budget": {
"tokens_remaining": budget.tokens_remaining,
"calls_remaining": budget.calls_remaining,
"window_remaining_secs": budget.window_remaining_secs,
"is_exhausted": budget.is_exhausted,
},
})
}
pub(crate) async fn handle_agent_trace(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use oxios_kernel::state_store::SessionId;
let agent = if let Ok(agents) = state.kernel.agents.list().await
&& let Some(agent) = agents.into_iter().find(|a| a.id.to_string() == id)
{
agent
} else if let Ok(Some(agent)) = state.kernel.agents.get(&id).await {
agent
} else {
return Err(AppError::NotFound("agent not found".into()));
};
let trajectory = if let Some(sid) = &agent.session_id {
match state
.kernel
.state
.load_session(&SessionId(sid.clone()))
.await
{
Ok(Some(session)) => Some(session.trajectory().to_vec()),
_ => None,
}
} else {
None
};
Ok(Json(trace_json(&agent, trajectory.as_deref())))
}
fn trace_json(
agent: &oxios_kernel::types::AgentInfo,
trajectory: Option<&[oxios_kernel::state_store::TrajectoryStepRecord]>,
) -> serde_json::Value {
use std::collections::HashSet;
let mut seen_ids: HashSet<String> = HashSet::new();
let mut steps: Vec<serde_json::Value> = Vec::new();
for (i, tc) in agent.tool_calls.iter().enumerate() {
if !tc.tool_call_id.is_empty() {
seen_ids.insert(tc.tool_call_id.clone());
}
steps.push(serde_json::json!({
"index": i,
"kind": "tool",
"tool_name": tc.tool,
"action": tc.tool,
"input": tc.input,
"output": tc.output,
"started_at": tc.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
"duration_ms": tc.duration_ms,
"status": if tc.is_error { "failed" } else { "completed" },
}));
}
if let Some(traj) = trajectory {
let mut idx = steps.len();
for step in traj {
if !step.tool_call_id.is_empty() && seen_ids.contains(&step.tool_call_id) {
continue;
}
seen_ids.insert(step.tool_call_id.clone());
steps.push(serde_json::json!({
"index": idx,
"kind": "tool",
"tool_name": step.tool_name,
"action": step.tool_name,
"input": step.tool_args,
"output": step.output_summary,
"started_at": step.timestamp.to_rfc3339(),
"duration_ms": step.duration_ms,
"status": if step.is_error { "failed" } else { "completed" },
}));
idx += 1;
}
steps.sort_by(|a, b| {
let ta = a["started_at"].as_str().unwrap_or("");
let tb = b["started_at"].as_str().unwrap_or("");
ta.cmp(tb)
});
for (i, step) in steps.iter_mut().enumerate() {
step["index"] = serde_json::json!(i);
}
}
serde_json::json!({
"agent_id": agent.id.to_string(),
"steps": steps,
"completed_at": agent.completed_at.map(|t| t.to_rfc3339()),
})
}
pub(crate) async fn handle_agent_logs(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let agent = if let Ok(agents) = state.kernel.agents.list().await {
agents.into_iter().find(|a| a.id.to_string() == id)
} else {
None
};
let agent = match agent {
Some(a) => a,
None => match state.kernel.agents.get(&id).await {
Ok(Some(a)) => a,
_ => return Err(AppError::NotFound("agent not found".into())),
},
};
let mut entries = Vec::new();
if let Some(started) = agent.started_at {
entries.push(serde_json::json!({
"timestamp": started.to_rfc3339(),
"level": "info",
"message": format!("Agent started: {}", agent.name),
}));
}
for (i, tc) in agent.tool_calls.iter().enumerate() {
let ts = tc.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default();
entries.push(serde_json::json!({
"timestamp": ts,
"level": "info",
"message": format!("[Step {}] {} ({}) → {}",
i + 1, tc.tool, format_duration(tc.duration_ms),
truncate_str(&tc.output, 120)),
}));
}
if let Some(completed) = agent.completed_at {
let (level, msg) = if let Some(ref err) = agent.error {
("error", format!("Agent failed: {err}"))
} else {
(
"info",
format!("Agent completed ({} steps)", agent.steps_completed),
)
};
entries.push(serde_json::json!({
"timestamp": completed.to_rfc3339(),
"level": level,
"message": msg,
}));
}
Ok(Json(serde_json::json!({
"agent_id": id,
"entries": entries,
})))
}
pub(crate) async fn handle_agent_kill(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<(), AppError> {
tracing::info!(agent_id = %id, "Kill agent requested");
state.kernel.agents.kill(&id).await.map_err(|e| {
tracing::warn!(error = %e, "Agent not found");
AppError::NotFound("agent not found".into())
})
}
pub(crate) async fn handle_config_get(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let config = state.config.read();
match serde_json::to_value(&*config) {
Ok(mut json) => {
if let Some(engine) = json.get_mut("engine")
&& let Some(api_key) = engine.get_mut("api_key")
&& api_key.as_str().is_some_and(|k| !k.is_empty())
{
*api_key = serde_json::Value::String("***".to_string());
}
json["engine"]["api_key_set"] =
serde_json::Value::Bool(config.engine.api_key.is_some());
Ok(Json(json))
}
Err(e) => {
tracing::error!(error = %e, "Failed to serialize config");
Err(AppError::Internal("failed to serialize config".into()))
}
}
}
fn deep_merge_json(base: &mut serde_json::Value, patch: serde_json::Value) {
use serde_json::Value;
if let Value::Object(patch_map) = patch {
if !base.is_object() {
*base = Value::Object(serde_json::Map::new());
}
let base_map = base.as_object_mut().expect("just ensured object");
for (key, patch_val) in patch_map {
match base_map.get_mut(&key) {
Some(existing) if existing.is_object() && patch_val.is_object() => {
deep_merge_json(existing, patch_val);
}
_ => {
base_map.insert(key, patch_val);
}
}
}
}
}
pub(crate) async fn handle_config_put(
state: State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, AppError> {
tracing::info!("Config update requested");
if let Some(forbidden) = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS) {
tracing::warn!(key = %forbidden, "PUT /api/config rejected forbidden key");
return Err(AppError::BadRequest(format!(
"PUT /api/config does not accept '{forbidden}' fields. \
Use the typed endpoint instead: \
/api/engine/api-key (POST), /api/engine/model (PUT), \
/api/engine/provider-options (PUT)."
)));
}
let mut current_value = {
let cfg = state.config.read();
serde_json::to_value(&*cfg).map_err(|e| {
tracing::error!(error = %e, "Failed to serialize current config");
AppError::Internal("failed to serialize current config".into())
})?
};
deep_merge_json(&mut current_value, body);
let updated: oxios_kernel::OxiosConfig = match serde_json::from_value(current_value.clone()) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(error = %e, "Invalid config shape");
return Err(AppError::BadRequest(format!("Invalid config: {e}")));
}
};
let (errors, warnings) = updated.validate();
for w in &warnings {
tracing::warn!(config_warning = %w, "Config validation warning");
}
if !errors.is_empty() {
let msg = errors.join("; ");
tracing::warn!(error = %msg, "Config validation failed");
return Err(AppError::BadRequest(format!("Invalid config: {msg}")));
}
let content = toml::to_string_pretty(&updated)
.map_err(|e: toml::ser::Error| AppError::Internal(e.to_string()))?;
if let Err(e) = tokio::fs::write(&state.config_path, content).await {
tracing::error!(error = %e, "Failed to persist config");
return Err(AppError::Internal(e.to_string()));
}
tracing::info!(path = %state.config_path.display(), "Config persisted");
let updated_config = updated;
*state.config.write() = updated_config.clone();
*state.kernel.exec.shared_config().write() = updated_config.exec.clone();
use oxios_kernel::resource_monitor::OverloadThreshold;
state
.kernel
.infra
.resource_monitor()
.set_overload_threshold(OverloadThreshold {
cpu_percent: updated_config.resource_monitor.cpu_threshold,
memory_percent: updated_config.resource_monitor.memory_threshold,
load_avg: updated_config.resource_monitor.load_threshold,
});
tracing::info!(
"Config hot-reloaded (web + kernel subsystems) from {}",
state.config_path.display()
);
let mut response = current_value;
if let Some(engine) = response.get_mut("engine")
&& let Some(api_key) = engine.get_mut("api_key")
&& api_key.as_str().is_some_and(|k| !k.is_empty())
{
*api_key = serde_json::Value::String("***".to_string());
}
if let Some(engine) = response.get_mut("engine") {
engine["api_key_set"] = serde_json::Value::Bool(updated_config.engine.api_key.is_some());
}
Ok(Json(response))
}
const HOT_RELOADABLE_SECTIONS: &[(&str, &str)] = &[
("exec", "exec_api"),
("resource_monitor", "resource_monitor"),
];
const RESTART_REQUIRED_FIELDS: &[&str] = &[
"memory.embedding.provider",
"memory.embedding.dimension",
"memory.sqlite.path",
"memory.sqlite.embedding_dim",
"memory.bridge.sync_enabled",
"memory.bridge.interval_secs",
"engine.default_model",
"engine.api_key",
"engine.provider_options",
"engine.routing_enabled",
"engine.prefer_cost_efficient",
"engine.fallback_models",
"engine.excluded_models",
"gateway.host",
"gateway.port",
"daemon.pid_file",
"daemon.log_dir",
"channels.enabled",
"channels.telegram.bot_token_env",
"channels.telegram.allowed_users",
"channels.telegram.session.rotation_hours",
"channels.telegram.session.max_messages",
"surfaces",
"otel.enabled",
"otel.endpoint",
"otel.service_name",
"otel.sampling_ratio",
"cron",
"mcp",
"browser",
"persona",
"marketplace",
"budget",
"git",
"memory.consolidation.preset",
];
#[derive(Debug, Serialize, Clone)]
pub(crate) struct ConfigPatchResponse {
pub config: serde_json::Value,
pub hot_reload: HotReloadReport,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct HotReloadReport {
pub applied_immediately: Vec<String>,
pub requires_restart: Vec<String>,
pub total_changed: usize,
}
fn classify_patch(
base: &serde_json::Value,
patch: &serde_json::Value,
prefix: &str,
applied: &mut Vec<String>,
restart: &mut Vec<String>,
) {
use serde_json::Value;
let Value::Object(patch_map) = patch else {
return;
};
for (key, patch_val) in patch_map {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if patch_val.is_object() {
let base_child = base.get(key).cloned().unwrap_or(Value::Null);
classify_patch(&base_child, patch_val, &path, applied, restart);
continue;
}
let base_val = base.get(key);
if base_val == Some(patch_val) {
continue;
}
if is_restart_required(&path) {
restart.push(path);
} else {
applied.push(path);
}
}
}
fn is_restart_required(path: &str) -> bool {
if RESTART_REQUIRED_FIELDS.contains(&path) {
return true;
}
let top = path.split('.').next().unwrap_or(path);
!HOT_RELOADABLE_SECTIONS.iter().any(|(s, _)| *s == top)
}
const PATCH_FORBIDDEN_TOP_LEVEL_KEYS: &[&str] = &["engine"];
fn find_forbidden_patch_key(body: &serde_json::Value, forbidden: &[&str]) -> Option<String> {
use serde_json::Value;
let Value::Object(map) = body else {
return None;
};
for key in map.keys() {
if forbidden.iter().any(|f| *f == key) {
return Some(key.clone());
}
}
None
}
pub(crate) async fn handle_config_patch(
state: State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<ConfigPatchResponse>, AppError> {
tracing::info!("Config PATCH requested");
if !body.is_object() {
return Err(AppError::BadRequest(
"PATCH body must be a JSON object".into(),
));
}
if let Some(forbidden) = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS) {
tracing::warn!(key = %forbidden, "PATCH /api/config rejected forbidden key");
return Err(AppError::BadRequest(format!(
"PATCH /api/config does not accept '{forbidden}' fields. \
Use the typed endpoint instead: \
/api/engine/api-key (POST), /api/engine/model (PUT), \
/api/engine/provider-options (PUT)."
)));
}
let mut current_value = {
let cfg = state.config.read();
serde_json::to_value(&*cfg).map_err(|e| {
tracing::error!(error = %e, "Failed to serialize current config");
AppError::Internal("failed to serialize current config".into())
})?
};
let before_patch = current_value.clone();
deep_merge_json(&mut current_value, body.clone());
let mut applied: Vec<String> = Vec::new();
let mut restart: Vec<String> = Vec::new();
classify_patch(&before_patch, &body, "", &mut applied, &mut restart);
applied.sort();
restart.sort();
let updated: oxios_kernel::OxiosConfig = match serde_json::from_value(current_value.clone()) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(error = %e, "Invalid config shape");
return Err(AppError::BadRequest(format!("Invalid config: {e}")));
}
};
let (errors, warnings) = updated.validate();
for w in &warnings {
tracing::warn!(config_warning = %w, "Config validation warning");
}
if !errors.is_empty() {
let msg = errors.join("; ");
tracing::warn!(error = %msg, "Config validation failed");
return Err(AppError::BadRequest(format!("Invalid config: {msg}")));
}
let content = toml::to_string_pretty(&updated)
.map_err(|e: toml::ser::Error| AppError::Internal(e.to_string()))?;
if let Err(e) = tokio::fs::write(&state.config_path, content).await {
tracing::error!(error = %e, "Failed to persist config");
return Err(AppError::Internal(e.to_string()));
}
tracing::info!(path = %state.config_path.display(), "Config persisted");
*state.config.write() = updated.clone();
*state.kernel.exec.shared_config().write() = updated.exec.clone();
use oxios_kernel::resource_monitor::OverloadThreshold;
state
.kernel
.infra
.resource_monitor()
.set_overload_threshold(OverloadThreshold {
cpu_percent: updated.resource_monitor.cpu_threshold,
memory_percent: updated.resource_monitor.memory_threshold,
load_avg: updated.resource_monitor.load_threshold,
});
let total = applied.len() + restart.len();
tracing::info!(
applied = applied.len(),
restart = restart.len(),
"Config PATCH applied"
);
Ok(Json(ConfigPatchResponse {
config: body,
hot_reload: HotReloadReport {
applied_immediately: applied,
requires_restart: restart,
total_changed: total,
},
}))
}
#[cfg(test)]
mod patch_tests {
use super::{classify_patch, is_restart_required};
use serde_json::json;
#[test]
fn classify_hot_reloadable_field() {
let base = json!({"exec": {"allowed_commands": ["ls", "cat"]}});
let patch = json!({"exec": {"allowed_commands": ["ls", "cat", "rg"]}});
let mut applied = Vec::new();
let mut restart = Vec::new();
classify_patch(&base, &patch, "", &mut applied, &mut restart);
assert_eq!(applied, vec!["exec.allowed_commands"]);
assert!(restart.is_empty());
}
#[test]
fn classify_restart_required_field() {
let base = json!({"gateway": {"port": 4200}});
let patch = json!({"gateway": {"port": 4300}});
let mut applied = Vec::new();
let mut restart = Vec::new();
classify_patch(&base, &patch, "", &mut applied, &mut restart);
assert!(applied.is_empty());
assert_eq!(restart, vec!["gateway.port"]);
}
#[test]
fn classify_mixed_changes() {
let base = json!({
"exec": {"allowed_commands": ["ls"]},
"gateway": {"port": 4200},
});
let patch = json!({
"exec": {"allowed_commands": ["ls", "rg"]},
"gateway": {"port": 4300},
});
let mut applied = Vec::new();
let mut restart = Vec::new();
classify_patch(&base, &patch, "", &mut applied, &mut restart);
applied.sort();
restart.sort();
assert_eq!(applied, vec!["exec.allowed_commands"]);
assert_eq!(restart, vec!["gateway.port"]);
}
#[test]
fn classify_skips_unchanged_fields() {
let base = json!({"exec": {"allowed_commands": ["ls"]}});
let patch = json!({"exec": {"allowed_commands": ["ls"]}});
let mut applied = Vec::new();
let mut restart = Vec::new();
classify_patch(&base, &patch, "", &mut applied, &mut restart);
assert!(applied.is_empty());
assert!(restart.is_empty());
}
#[test]
fn classify_recurses_into_nested_objects() {
let base = json!({
"memory": {"embedding": {"provider": "gguf", "dimension": 256}}
});
let patch = json!({
"memory": {"embedding": {"provider": "mlx", "dimension": 256}}
});
let mut applied = Vec::new();
let mut restart = Vec::new();
classify_patch(&base, &patch, "", &mut applied, &mut restart);
assert!(applied.is_empty());
assert_eq!(restart, vec!["memory.embedding.provider"]);
}
#[test]
fn unknown_top_level_section_is_restart_required() {
assert!(is_restart_required("otel.enabled"));
assert!(is_restart_required("otel.endpoint"));
}
#[test]
fn hot_reloadable_sections_are_immediate() {
assert!(!is_restart_required("exec.allowed_commands"));
assert!(!is_restart_required("resource_monitor.cpu_threshold"));
}
#[test]
fn security_section_is_restart_required() {
assert!(is_restart_required("security.cors_origins"));
assert!(is_restart_required("security.auth_enabled"));
assert!(is_restart_required("security.rate_limit_per_minute"));
}
#[test]
fn audit_section_is_restart_required() {
assert!(is_restart_required("audit.max_entries"));
assert!(is_restart_required("audit.enabled"));
}
#[test]
fn memory_section_is_restart_required() {
assert!(is_restart_required("memory.enabled"));
assert!(is_restart_required("memory.embedding.provider"));
assert!(is_restart_required("memory.consolidation.dream_enabled"));
assert!(is_restart_required("memory.learning.sona_enabled"));
}
#[test]
fn channels_telegram_session_requires_restart() {
assert!(is_restart_required(
"channels.telegram.session.rotation_hours"
));
assert!(is_restart_required("channels.telegram.allowed_users"));
}
#[test]
fn memory_consolidation_preset_requires_restart() {
assert!(is_restart_required("memory.consolidation.preset"));
}
}
#[cfg(test)]
mod patch_rejection_tests {
use super::{PATCH_FORBIDDEN_TOP_LEVEL_KEYS, find_forbidden_patch_key};
use serde_json::json;
#[test]
fn rejects_engine_api_key() {
let body = json!({"engine": {"api_key": "sk-secret"}});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert_eq!(found.as_deref(), Some("engine"));
}
#[test]
fn rejects_engine_provider_options() {
let body = json!({"engine": {"provider_options": {"temperature": 0.7}}});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert_eq!(found.as_deref(), Some("engine"));
}
#[test]
fn rejects_engine_default_model() {
let body = json!({"engine": {"default_model": "anthropic/claude-3"}});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert_eq!(found.as_deref(), Some("engine"));
}
#[test]
fn accepts_non_engine_sections() {
let body = json!({
"exec": {"allowlist_mode": "enforced"},
});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert!(found.is_none());
}
#[test]
fn accepts_mixed_payload_without_engine() {
let body = json!({"exec": {"allowed_commands": ["engine-status"]}});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert!(found.is_none());
}
#[test]
fn empty_body_is_acceptable() {
let body = json!({});
let found = find_forbidden_patch_key(&body, PATCH_FORBIDDEN_TOP_LEVEL_KEYS);
assert!(found.is_none());
}
}
#[cfg(test)]
mod deep_merge_tests {
use super::deep_merge_json;
use serde_json::json;
#[test]
fn preserves_omitted_top_level_sections() {
let mut base = json!({
"kernel": {"workspace": "~/.oxios/workspace", "max_agents": 10},
"exec": {"allowed_commands": ["ls", "cat"], "allowlist_mode": "enforced"},
});
let patch = json!({
"kernel": {"max_agents": 20},
});
deep_merge_json(&mut base, patch);
assert_eq!(base["kernel"]["workspace"], "~/.oxios/workspace");
assert_eq!(base["kernel"]["max_agents"], 20);
assert_eq!(base["exec"]["allowed_commands"][0], "ls");
assert_eq!(base["exec"]["allowlist_mode"], "enforced");
}
#[test]
fn patch_value_replaces_scalar() {
let mut base = json!({"engine": {"default_model": "old/model"}});
deep_merge_json(&mut base, json!({"engine": {"default_model": "new/model"}}));
assert_eq!(base["engine"]["default_model"], "new/model");
}
#[test]
fn patch_object_replaces_object() {
let mut base = json!({"security": {"auth_enabled": false, "cors_origins": ["http://a"]}});
deep_merge_json(&mut base, json!({"security": {"auth_enabled": true}}));
assert_eq!(base["security"]["auth_enabled"], true);
assert_eq!(base["security"]["cors_origins"][0], "http://a");
}
#[test]
fn empty_patch_is_noop() {
let mut base = json!({"exec": {"allowed_commands": ["ls"]}});
let original = base.clone();
deep_merge_json(&mut base, json!({}));
assert_eq!(base, original);
}
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct DoctorCheck {
pub name: String,
pub status: String,
pub message: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct DoctorResponse {
pub checks: u32,
pub issues: u32,
pub results: Vec<DoctorCheck>,
pub action_items: Vec<String>,
}
pub(crate) async fn handle_doctor(state: State<Arc<AppState>>) -> Json<DoctorResponse> {
let (default_model, api_key, workspace, _daemon_log_dir) = {
let config = state.config.read();
(
config.engine.default_model.clone(),
config.api_key(),
config.kernel.workspace.clone(),
config.daemon.log_dir.clone(),
)
};
let mut results = Vec::new();
let mut action_items = Vec::new();
if state.config_path.exists() {
results.push(DoctorCheck {
name: "config_file".into(),
status: "pass".into(),
message: format!("Config file present ({})", state.config_path.display()),
});
} else {
results.push(DoctorCheck {
name: "config_file".into(),
status: "fail".into(),
message: "Config file missing".into(),
});
action_items.push("Config file not found. Run `oxios onboard` to create it.".into());
}
let provider = oxios_kernel::CredentialStore::provider_from_model(&default_model);
match provider {
Some(p) => match oxios_kernel::CredentialStore::resolve(p, api_key.as_deref()) {
Some((_key, source)) => {
results.push(DoctorCheck {
name: "credentials".into(),
status: "pass".into(),
message: format!("Credentials found (via {source:?})"),
});
}
None => {
results.push(DoctorCheck {
name: "credentials".into(),
status: "fail".into(),
message: format!("No credentials for provider '{p}'"),
});
action_items.push(format!(
"No API key for '{p}'. Configure in Settings → Engine."
));
}
},
None => {
results.push(DoctorCheck {
name: "credentials".into(),
status: "fail".into(),
message: "No model configured".into(),
});
action_items.push("No model set. Configure in Settings → Engine.".into());
}
}
let workspace = oxios_kernel::config::expand_home(&workspace);
if workspace.exists() {
results.push(DoctorCheck {
name: "workspace".into(),
status: "pass".into(),
message: format!("Workspace directory ({})", workspace.display()),
});
} else {
results.push(DoctorCheck {
name: "workspace".into(),
status: "warn".into(),
message: format!("Workspace directory missing ({})", workspace.display()),
});
action_items.push("Workspace directory not found. It will be created on first run.".into());
}
if !default_model.is_empty() {
results.push(DoctorCheck {
name: "model".into(),
status: "pass".into(),
message: format!("Default model: {default_model}"),
});
} else {
results.push(DoctorCheck {
name: "model".into(),
status: "fail".into(),
message: "No default model set".into(),
});
action_items.push("No default model configured.".into());
}
let mcp_count = state.kernel.mcp.server_count();
if mcp_count > 0 {
results.push(DoctorCheck {
name: "mcp_servers".into(),
status: "pass".into(),
message: format!("{mcp_count} MCP server(s) connected"),
});
} else {
results.push(DoctorCheck {
name: "mcp_servers".into(),
status: "warn".into(),
message: "No MCP servers configured".into(),
});
}
let git_ok = state.kernel.infra.git_verify().unwrap_or(false);
if git_ok {
results.push(DoctorCheck {
name: "git".into(),
status: "pass".into(),
message: "Git repository intact".into(),
});
} else {
results.push(DoctorCheck {
name: "git".into(),
status: "warn".into(),
message: "Git repository verification failed".into(),
});
}
let ws_path = state.kernel.state.workspace_path();
if ws_path.exists() {
results.push(DoctorCheck {
name: "state_store".into(),
status: "pass".into(),
message: format!("State store path exists ({})", ws_path.display()),
});
} else {
results.push(DoctorCheck {
name: "state_store".into(),
status: "warn".into(),
message: "State store path not found".into(),
});
}
let (index_size, total) = state.kernel.agents.memory_stats().await;
results.push(DoctorCheck {
name: "memory".into(),
status: "pass".into(),
message: format!("Memory: {index_size} indexed, {total} total entries"),
});
if let Some(web_dist) = state.web_dist.path() {
if web_dist.exists() {
results.push(DoctorCheck {
name: "web_dist".into(),
status: "pass".into(),
message: format!("Web UI dist ({})", web_dist.display()),
});
} else {
results.push(DoctorCheck {
name: "web_dist".into(),
status: "warn".into(),
message: "Web UI dist directory not found".into(),
});
}
} else {
results.push(DoctorCheck {
name: "web_dist".into(),
status: "pass".into(),
message: "Web UI served from embedded assets".into(),
});
}
let checks = results.len() as u32;
let issues = action_items.len() as u32;
Json(DoctorResponse {
checks,
issues,
results,
action_items,
})
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct AuditVerifyResponse {
pub valid: bool,
pub entries_checked: u64,
pub message: String,
}
pub(crate) async fn handle_audit_verify_api(
state: State<Arc<AppState>>,
) -> Json<AuditVerifyResponse> {
let audit = &state.kernel.security;
match audit.verify_chain() {
Ok(valid) => Json(AuditVerifyResponse {
valid,
entries_checked: 0,
message: if valid {
"Audit trail verified successfully.".into()
} else {
"Audit trail verification failed.".into()
},
}),
Err(e) => Json(AuditVerifyResponse {
valid: false,
entries_checked: 0,
message: format!("Audit trail verification failed: {e}"),
}),
}
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct BackupResponse {
pub success: bool,
pub path: String,
pub size_bytes: u64,
pub message: String,
}
pub(crate) async fn handle_backup(_state: State<Arc<AppState>>) -> Json<BackupResponse> {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
return Json(BackupResponse {
success: false,
path: String::new(),
size_bytes: 0,
message: "Cannot determine home directory.".into(),
});
}
};
let oxios_home = home.join(".oxios");
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let backup_name = format!("oxios-backup-{timestamp}.tar.gz");
let backup_path = oxios_home.join(&backup_name);
tracing::info!(path = %backup_path.display(), "Creating backup");
let output = match tokio::process::Command::new("tar")
.args([
"-czf",
match backup_path.to_str() {
Some(s) => s,
None => {
return Json(BackupResponse {
success: false,
path: String::new(),
size_bytes: 0,
message: "Invalid backup path.".into(),
});
}
},
"-C",
oxios_home.to_str().unwrap_or("."),
"config.toml",
"workspace",
"knowledge",
])
.output()
.await
{
Ok(o) => o,
Err(e) => {
return Json(BackupResponse {
success: false,
path: String::new(),
size_bytes: 0,
message: format!("tar failed: {e}"),
});
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Json(BackupResponse {
success: false,
path: String::new(),
size_bytes: 0,
message: format!("Backup failed: {stderr}"),
});
}
let size = std::fs::metadata(&backup_path)
.map(|m| m.len())
.unwrap_or(0);
tracing::info!(
path = %backup_path.display(),
size,
"Backup created"
);
Json(BackupResponse {
success: true,
path: backup_path.display().to_string(),
size_bytes: size,
message: format!(
"Backup created: {backup_name} ({})",
format_size_helper(size)
),
})
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct LogResponse {
pub lines: Vec<String>,
pub total: usize,
}
pub(crate) async fn handle_log(state: State<Arc<AppState>>) -> Json<LogResponse> {
let log_dir = {
let config = state.config.read();
oxios_kernel::config::expand_home(&config.daemon.log_dir)
};
let log_file = log_dir.join("oxios.log");
if !log_file.exists() {
return Json(LogResponse {
lines: vec!["No log file found.".into()],
total: 1,
});
}
let (lines, total) = read_log_tail(&log_file, 50, 256 * 1024);
Json(LogResponse { lines, total })
}
fn read_log_tail(path: &std::path::Path, max_lines: usize, max_bytes: u64) -> (Vec<String>, usize) {
use std::io::{Read, Seek, SeekFrom};
let Ok(metadata) = std::fs::metadata(path) else {
return (Vec::new(), 0);
};
let size = metadata.len();
let Ok(mut file) = std::fs::File::open(path) else {
return (Vec::new(), 0);
};
let window = size.min(max_bytes);
if size > max_bytes && file.seek(SeekFrom::End(-(window as i64))).is_err() {
return (Vec::new(), 0);
}
let mut bytes = Vec::with_capacity(window as usize);
if file.read_to_end(&mut bytes).is_err() {
return (Vec::new(), 0);
}
if size > max_bytes
&& let Some(nl) = bytes.iter().position(|&b| b == b'\n')
{
bytes.drain(..=nl);
}
let content = String::from_utf8_lossy(&bytes);
let all_lines: Vec<&str> = content.lines().collect();
let total = all_lines.len();
let start = all_lines.len().saturating_sub(max_lines);
let lines = all_lines[start..].iter().map(|s| s.to_string()).collect();
(lines, total)
}
fn format_size_helper(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn format_duration(ms: u64) -> String {
if ms < 1000 {
format!("{ms}ms")
} else {
format!("{:.1}s", ms as f64 / 1000.0)
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{truncated}...")
}
}