use anyhow::Result;
use serde_json::{json, Value};
use trusty_common::console_metrics::{make_report, ServiceHealth};
use trusty_common::memory_core::PalaceRegistry;
use crate::AppState;
const MAX_PALACES_IN_REPORT: usize = 20;
pub fn descriptor() -> Value {
json!({
"name": "console_metrics",
"description": "Return a ConsoleMetricsReport with palace aggregate statistics \
(palace_count, cached_palace_count, total_drawers, total_vectors, \
total_kg_triples) and per-palace detail (first 20). Aggregate counts and \
per-palace detail only reflect palaces already resident in the open-handle \
LRU cache — palaces on disk that are not currently open contribute to \
palace_count but not to the count fields (each entry carries a `cached` \
bool). Used by the trusty-console dashboard metrics poller.",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
})
}
struct PalaceStats {
palace_count: usize,
cached_palace_count: usize,
total_drawers: usize,
total_vectors: usize,
total_kg_triples: usize,
palace_entries: Vec<Value>,
}
pub async fn handle_console_metrics(state: &AppState, _args: Value) -> Result<Value> {
let root = state.data_root.clone();
let palace_infos =
match tokio::task::spawn_blocking(move || PalaceRegistry::list_palaces(&root))
.await
.map_err(|e| anyhow::anyhow!("join list_palaces: {e}"))?
{
Ok(v) => v,
Err(e) => {
tracing::warn!("console_metrics: list_palaces failed: {e:#}");
Vec::new()
}
};
let stats = collect_palace_stats(&state.registry, &palace_infos);
let metrics = json!({
"palace_count": stats.palace_count,
"cached_palace_count": stats.cached_palace_count,
"total_drawers": stats.total_drawers,
"total_vectors": stats.total_vectors,
"total_kg_triples": stats.total_kg_triples,
"palaces": stats.palace_entries,
});
let report = make_report(
"trusty-memory",
"Trusty Memory",
env!("CARGO_PKG_VERSION"),
ServiceHealth::Ok,
metrics,
2,
);
Ok(serde_json::to_value(&report)?)
}
fn collect_palace_stats(
registry: &trusty_common::memory_core::PalaceRegistry,
palace_infos: &[trusty_common::memory_core::Palace],
) -> PalaceStats {
let palace_count = palace_infos.len();
let mut total_drawers: usize = 0;
let mut total_vectors: usize = 0;
let mut total_kg_triples: usize = 0;
let mut cached_palace_count: usize = 0;
let mut palace_entries: Vec<Value> =
Vec::with_capacity(palace_count.min(MAX_PALACES_IN_REPORT));
for info in palace_infos.iter().take(MAX_PALACES_IN_REPORT) {
let palace_id = info.id.as_str().to_string();
let name = info.name.clone();
match registry.peek(&info.id) {
Some(handle) => {
let drawer_count = handle.drawers.read().len();
let vector_count = handle.vector_store.index_size();
let kg_triple_count = handle.kg.count_active_triples();
total_drawers += drawer_count;
total_vectors += vector_count;
total_kg_triples += kg_triple_count;
cached_palace_count += 1;
palace_entries.push(json!({
"id": palace_id,
"name": name,
"drawer_count": drawer_count,
"vector_count": vector_count,
"kg_triple_count": kg_triple_count,
"cached": true,
}));
}
None => {
palace_entries.push(json!({
"id": palace_id,
"name": name,
"drawer_count": 0,
"vector_count": 0,
"kg_triple_count": 0,
"cached": false,
}));
}
}
}
for info in palace_infos.iter().skip(MAX_PALACES_IN_REPORT) {
if let Some(handle) = registry.peek(&info.id) {
total_drawers += handle.drawers.read().len();
total_vectors += handle.vector_store.index_size();
total_kg_triples += handle.kg.count_active_triples();
cached_palace_count += 1;
}
}
PalaceStats {
palace_count,
cached_palace_count,
total_drawers,
total_vectors,
total_kg_triples,
palace_entries,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[serial_test::serial]
#[tokio::test]
async fn handle_console_metrics_returns_valid_report() {
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let tmp = tempfile::tempdir().expect("tempdir");
let state = crate::AppState::new(tmp.path().to_path_buf());
let result = handle_console_metrics(&state, serde_json::json!({}))
.await
.expect("console_metrics must not return Err");
assert_eq!(result["service_id"], "trusty-memory");
assert_eq!(result["display_name"], "Trusty Memory");
assert!(result["version"].is_string());
assert!(result["status"].is_string());
assert_eq!(result["metrics_schema_version"], 2);
assert!(result["collected_at_unix"].is_number());
assert_eq!(result["metrics"]["palace_count"], 0);
assert_eq!(result["metrics"]["cached_palace_count"], 0);
assert_eq!(result["metrics"]["total_drawers"], 0);
assert_eq!(result["metrics"]["total_vectors"], 0);
assert_eq!(result["metrics"]["total_kg_triples"], 0);
assert!(result["metrics"]["palaces"].is_array());
assert_eq!(result["metrics"]["palaces"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn console_metrics_uses_cache_only_and_does_not_evict() {
use trusty_common::memory_core::{Palace, PalaceId};
let tmp = tempfile::tempdir().expect("tempdir");
let data_root = tmp.path().to_path_buf();
let registry = PalaceRegistry::with_max_open(2);
for name in ["a", "b", "c"] {
let palace = Palace {
id: PalaceId::new(name),
name: name.to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: data_root.join(name),
};
registry
.create_palace(&data_root, palace)
.unwrap_or_else(|e| panic!("create_palace({name}) failed: {e:#}"));
}
assert_eq!(
registry.len(),
2,
"capacity-2 registry must hold only 2 handles after 3 creates"
);
assert!(
registry.peek(&PalaceId::new("a")).is_none(),
"'a' must already be evicted before console_metrics runs"
);
let mut state = crate::AppState::new(data_root);
state.registry = std::sync::Arc::new(registry);
let result = handle_console_metrics(&state, serde_json::json!({}))
.await
.expect("console_metrics must not return Err");
assert_eq!(
result["metrics"]["palace_count"], 3,
"palace_count reflects all 3 on-disk palaces"
);
assert_eq!(
result["metrics"]["cached_palace_count"], 2,
"cached_palace_count reflects only the 2 still-resident handles"
);
assert_eq!(
state.registry.len(),
2,
"console_metrics must not grow the LRU cache"
);
assert!(
state.registry.peek(&PalaceId::new("a")).is_none(),
"console_metrics must not reopen the evicted palace 'a'"
);
assert!(state.registry.peek(&PalaceId::new("b")).is_some());
assert!(state.registry.peek(&PalaceId::new("c")).is_some());
let entries = result["metrics"]["palaces"]
.as_array()
.expect("palaces array present");
assert_eq!(entries.len(), 3);
let entry = |id: &str| {
entries
.iter()
.find(|e| e["id"] == id)
.unwrap_or_else(|| panic!("entry for '{id}' present"))
};
assert_eq!(entry("a")["cached"], false, "'a' is not cached");
assert_eq!(entry("b")["cached"], true, "'b' is cached");
assert_eq!(entry("c")["cached"], true, "'c' is cached");
}
}