use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthResponse {
pub status: String,
#[serde(default)]
pub uptime_secs: u64,
#[serde(default)]
pub active_connections: i64,
#[serde(default)]
pub pipeline_lag_ms: u64,
}
pub fn redact_database_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let authority_start = scheme_end + 3;
let authority_end = url[authority_start..]
.find(['/', '?', '#'])
.map(|i| authority_start + i)
.unwrap_or(url.len());
let authority = &url[authority_start..authority_end];
let Some(at_idx) = authority.rfind('@') else {
return url.to_string();
};
let userinfo = &authority[..at_idx];
let Some(colon_idx) = userinfo.find(':') else {
return url.to_string();
};
let user = &userinfo[..colon_idx];
let host_and_rest = &url[authority_start + at_idx..];
format!("{}://{}:***{}", &url[..scheme_end], user, host_and_rest)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthzResponse {
pub mode: String,
pub version: String,
pub storage: String,
pub uptime_secs: u64,
#[serde(default)]
pub storage_path: Option<String>,
#[serde(default)]
pub database_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AdminRowCountsBlock {
pub audit_events_hot: u64,
pub agents: u64,
pub policy_versions: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminTimescaleDbBlock {
pub enabled: bool,
pub total_chunks: u32,
pub compressed_chunks: u32,
pub compression_ratio: f32,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminStorageHealthBlock {
pub backend: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_url: Option<String>,
pub health: String,
pub latency_ms: u32,
pub row_counts: AdminRowCountsBlock,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timescaledb: Option<AdminTimescaleDbBlock>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminStatusResponse {
pub mode: String,
pub version: String,
pub uptime_secs: u64,
pub storage: AdminStorageHealthBlock,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DeploymentOverview {
pub mode: String,
pub gateway_url: String,
pub storage_backend: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_url_redacted: Option<String>,
pub version: String,
pub uptime_secs: u64,
pub health: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RuntimeHealth {
pub reachable: bool,
pub status: String,
pub uptime_secs: u64,
pub active_connections: i64,
pub pipeline_lag_ms: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentResponse {
pub id: String,
pub name: String,
pub framework: String,
pub version: String,
pub status: String,
pub tool_names: Vec<String>,
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub session_count: u32,
#[serde(default)]
pub policy_violations_count: u32,
#[serde(default)]
pub layer: Option<String>,
#[serde(default)]
pub last_event: Option<String>,
#[serde(default)]
pub recent_events: Vec<RecentEventResponse>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RecentEventResponse {
pub event_type: String,
pub summary: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentRow {
pub id: String,
pub name: String,
pub framework: String,
pub status: String,
pub sessions: u32,
pub violations_today: u32,
pub last_event: String,
pub layer: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApprovalResponse {
pub id: String,
pub agent_id: String,
pub action: String,
pub reason: String,
pub status: String,
pub created_at: String,
#[serde(default)]
pub team_id: String,
#[serde(default)]
pub routing_status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApprovalsSummary {
pub pending_count: usize,
pub oldest_pending_age: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentCostEntry {
pub agent_id: String,
pub daily_spend_usd: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CostResponse {
pub daily_spend_usd: String,
pub monthly_spend_usd: Option<String>,
pub date: String,
#[serde(default)]
pub daily_limit_usd: Option<String>,
#[serde(default)]
pub monthly_limit_usd: Option<String>,
#[serde(default)]
pub per_agent: Vec<AgentCostEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BudgetRow {
pub daily_spend_usd: String,
pub monthly_spend_usd: Option<String>,
pub daily_limit_usd: Option<String>,
pub monthly_limit_usd: Option<String>,
pub date: String,
pub per_agent: Vec<AgentCostEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u32,
pub per_page: u32,
pub total: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatusSnapshot {
pub deployment: DeploymentOverview,
pub runtime: RuntimeHealth,
pub agents: Vec<AgentRow>,
pub approvals: ApprovalsSummary,
pub budget: BudgetRow,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_health: Option<AdminStorageHealthBlock>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_response_deserializes_minimal() {
let json = r#"{"status":"ok"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.uptime_secs, 0);
assert_eq!(resp.active_connections, 0);
assert_eq!(resp.pipeline_lag_ms, 0);
}
#[test]
fn health_response_deserializes_with_new_fields() {
let json = r#"{"status":"ok","uptime_secs":3600,"active_connections":5,"pipeline_lag_ms":12}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.uptime_secs, 3600);
assert_eq!(resp.active_connections, 5);
assert_eq!(resp.pipeline_lag_ms, 12);
}
#[test]
fn healthz_response_deserializes_minimal_body() {
let json = r#"{"mode":"local","version":"0.0.1","storage":"sqlite","uptime_secs":0}"#;
let resp: HealthzResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.mode, "local");
assert_eq!(resp.version, "0.0.1");
assert_eq!(resp.storage, "sqlite");
assert_eq!(resp.uptime_secs, 0);
assert!(resp.storage_path.is_none());
assert!(resp.database_url.is_none());
}
#[test]
fn redact_database_url_replaces_postgres_password() {
let redacted = redact_database_url("postgresql://aasm:secret@aasm-db:5432/aasm");
assert_eq!(redacted, "postgresql://aasm:***@aasm-db:5432/aasm");
}
#[test]
fn redact_database_url_leaves_no_password_url_unchanged() {
let input = "postgresql://aasm@aasm-db:5432/aasm";
assert_eq!(redact_database_url(input), input);
}
#[test]
fn redact_database_url_leaves_sqlite_url_unchanged() {
let input = "sqlite:///home/dev/.aasm/local.db";
assert_eq!(redact_database_url(input), input);
}
#[test]
fn redact_database_url_leaves_malformed_input_unchanged() {
for input in ["~/.aasm/local.db", "not-a-url", "://no-scheme", ""] {
assert_eq!(redact_database_url(input), input, "input: {input:?}");
}
}
#[test]
fn deployment_overview_serialises_with_documented_field_names() {
let overview = DeploymentOverview {
mode: "remote".to_string(),
gateway_url: "https://cp.company.internal:7391".to_string(),
storage_backend: "postgres".to_string(),
storage_path: None,
database_url_redacted: Some("postgresql://aasm:***@aasm-db:5432/aasm".to_string()),
version: "0.0.1".to_string(),
uptime_secs: 8133,
health: "ok".to_string(),
};
let json = serde_json::to_value(&overview).expect("DeploymentOverview must serialise");
assert_eq!(json["mode"], "remote");
assert_eq!(json["gateway_url"], "https://cp.company.internal:7391");
assert_eq!(json["storage_backend"], "postgres");
assert_eq!(json["database_url_redacted"], "postgresql://aasm:***@aasm-db:5432/aasm");
assert_eq!(json["version"], "0.0.1");
assert_eq!(json["uptime_secs"], 8133);
assert_eq!(json["health"], "ok");
assert!(json.get("storage_path").is_none(), "Option::None must be skipped");
}
#[test]
fn healthz_response_deserializes_with_storage_path_and_database_url() {
let json = r#"{
"mode": "remote",
"version": "0.0.1",
"storage": "postgres",
"uptime_secs": 8133,
"storage_path": "~/.aasm/local.db",
"database_url": "postgresql://user:secret@aasm-db:5432/aasm"
}"#;
let resp: HealthzResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.mode, "remote");
assert_eq!(resp.storage, "postgres");
assert_eq!(resp.uptime_secs, 8133);
assert_eq!(resp.storage_path.as_deref(), Some("~/.aasm/local.db"));
assert_eq!(
resp.database_url.as_deref(),
Some("postgresql://user:secret@aasm-db:5432/aasm")
);
}
#[test]
fn admin_row_counts_block_deserialises_documented_keys() {
let json = r#"{"audit_events_hot": 14293, "agents": 8, "policy_versions": 3}"#;
let block: AdminRowCountsBlock = serde_json::from_str(json).unwrap();
assert_eq!(block.audit_events_hot, 14_293);
assert_eq!(block.agents, 8);
assert_eq!(block.policy_versions, 3);
}
#[test]
fn admin_row_counts_block_tolerates_extra_keys() {
let json = r#"{
"audit_events_hot": 1,
"audit_events_warm": 99,
"agents": 1,
"policy_versions": 1
}"#;
let block: AdminRowCountsBlock = serde_json::from_str(json).unwrap();
assert_eq!(block.audit_events_hot, 1);
}
#[test]
fn admin_status_response_deserialises_postgres_with_timescaledb() {
let json = r#"{
"mode": "remote",
"version": "0.0.1",
"uptime_secs": 86400,
"storage": {
"backend": "postgres",
"database_url": "postgresql://aasm:***@db.internal:5432/aasm",
"health": "ok",
"latency_ms": 3,
"row_counts": {
"audit_events_hot": 14293,
"agents": 8,
"policy_versions": 3
},
"timescaledb": {
"enabled": true,
"total_chunks": 12,
"compressed_chunks": 8,
"compression_ratio": 11.4
}
}
}"#;
let resp: AdminStatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.mode, "remote");
assert_eq!(resp.storage.backend, "postgres");
assert_eq!(resp.storage.health, "ok");
assert_eq!(resp.storage.latency_ms, 3);
assert!(resp.storage.path.is_none(), "postgres branch must omit path");
assert_eq!(
resp.storage.database_url.as_deref(),
Some("postgresql://aasm:***@db.internal:5432/aasm")
);
let ts = resp.storage.timescaledb.expect("timescaledb block present");
assert_eq!(ts.total_chunks, 12);
assert_eq!(ts.compressed_chunks, 8);
}
#[test]
fn admin_status_response_deserialises_sqlite_without_timescaledb() {
let json = r#"{
"mode": "local",
"version": "0.0.1",
"uptime_secs": 60,
"storage": {
"backend": "sqlite",
"path": "~/.aasm/local.db",
"health": "ok",
"latency_ms": 1,
"row_counts": {
"audit_events_hot": 47,
"agents": 2,
"policy_versions": 1
}
}
}"#;
let resp: AdminStatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.storage.backend, "sqlite");
assert_eq!(resp.storage.path.as_deref(), Some("~/.aasm/local.db"));
assert!(
resp.storage.database_url.is_none(),
"sqlite branch must omit database_url"
);
assert!(resp.storage.timescaledb.is_none(), "sqlite must omit timescaledb block");
}
#[test]
fn admin_timescaledb_block_deserialises_documented_keys() {
let json = r#"{
"enabled": true,
"total_chunks": 12,
"compressed_chunks": 8,
"compression_ratio": 11.4
}"#;
let block: AdminTimescaleDbBlock = serde_json::from_str(json).unwrap();
assert!(block.enabled);
assert_eq!(block.total_chunks, 12);
assert_eq!(block.compressed_chunks, 8);
assert!((block.compression_ratio - 11.4).abs() < 0.05);
}
#[test]
fn agent_response_deserializes() {
let json = r#"{
"id": "abc123",
"name": "support-agent",
"framework": "langgraph",
"version": "1.0.0",
"status": "Running",
"tool_names": ["query_db", "send_slack"],
"metadata": {"team": "support"}
}"#;
let resp: AgentResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "abc123");
assert_eq!(resp.name, "support-agent");
assert_eq!(resp.framework, "langgraph");
assert_eq!(resp.tool_names.len(), 2);
assert_eq!(resp.metadata.get("team").unwrap(), "support");
assert_eq!(resp.session_count, 0);
assert_eq!(resp.policy_violations_count, 0);
assert!(resp.layer.is_none());
assert!(resp.last_event.is_none());
assert!(resp.recent_events.is_empty());
}
#[test]
fn agent_response_deserializes_with_new_fields() {
let json = r#"{
"id": "abc123",
"name": "full-agent",
"framework": "crewai",
"version": "2.0.0",
"status": "Active",
"tool_names": [],
"metadata": {},
"session_count": 5,
"policy_violations_count": 2,
"layer": "enforced",
"last_event": "2026-05-01T08:00:00Z",
"recent_events": [
{"event_type": "tool_call", "summary": "called bash", "timestamp": "2026-05-01T08:00:00Z"}
]
}"#;
let resp: AgentResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.session_count, 5);
assert_eq!(resp.policy_violations_count, 2);
assert_eq!(resp.layer.as_deref(), Some("enforced"));
assert_eq!(resp.last_event.as_deref(), Some("2026-05-01T08:00:00Z"));
assert_eq!(resp.recent_events.len(), 1);
assert_eq!(resp.recent_events[0].event_type, "tool_call");
}
#[test]
fn approval_response_deserializes() {
let json = r#"{
"id": "ap-001",
"agent_id": "abc123",
"action": "process_refund",
"reason": "amount exceeds $100",
"status": "pending",
"created_at": "2026-04-30T10:00:00Z"
}"#;
let resp: ApprovalResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "ap-001");
assert_eq!(resp.status, "pending");
assert_eq!(resp.created_at, "2026-04-30T10:00:00Z");
assert!(resp.team_id.is_empty());
assert!(resp.routing_status.is_empty());
}
#[test]
fn approval_response_deserializes_with_routing_fields() {
let json = r#"{
"id": "ap-002",
"agent_id": "abc123",
"action": "dangerous_action",
"reason": "requires_approval",
"status": "pending",
"created_at": "2026-05-01T09:00:00Z",
"team_id": "team-x",
"routing_status": "routed:team-x"
}"#;
let resp: ApprovalResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.team_id, "team-x");
assert_eq!(resp.routing_status, "routed:team-x");
}
#[test]
fn cost_response_deserializes() {
let json = r#"{
"daily_spend_usd": "8.10",
"monthly_spend_usd": "142.50",
"date": "2026-04-30",
"daily_limit_usd": "100.00",
"monthly_limit_usd": "2000.00",
"per_agent": [
{"agent_id": "abc123", "daily_spend_usd": "4.10"}
]
}"#;
let resp: CostResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.daily_spend_usd, "8.10");
assert_eq!(resp.monthly_spend_usd.as_deref(), Some("142.50"));
assert_eq!(resp.date, "2026-04-30");
assert_eq!(resp.daily_limit_usd.as_deref(), Some("100.00"));
assert_eq!(resp.monthly_limit_usd.as_deref(), Some("2000.00"));
assert_eq!(resp.per_agent.len(), 1);
assert_eq!(resp.per_agent[0].agent_id, "abc123");
assert_eq!(resp.per_agent[0].daily_spend_usd, "4.10");
}
#[test]
fn cost_response_deserializes_without_monthly() {
let json = r#"{"daily_spend_usd": "0.00", "date": "2026-04-30"}"#;
let resp: CostResponse = serde_json::from_str(json).unwrap();
assert!(resp.monthly_spend_usd.is_none());
}
#[test]
fn cost_response_deserializes_without_new_fields() {
let json = r#"{
"daily_spend_usd": "5.00",
"monthly_spend_usd": "50.00",
"date": "2026-04-30"
}"#;
let resp: CostResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.daily_spend_usd, "5.00");
assert!(resp.daily_limit_usd.is_none());
assert!(resp.monthly_limit_usd.is_none());
assert!(resp.per_agent.is_empty());
}
}