use crate::app::App;
use crate::collector::mcp::ACTIVE_MTIME_SECS;
use crate::host_info::{AgentAggregate, HostMetrics};
use crate::model::{
ChatRole, ChildProcess, OrphanPort, RateLimitInfo, SessionStatus, MAX_CHAT_MESSAGES,
};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
pub generated_at_ms: u64,
pub host: Option<HostMetrics>,
pub aggregate: AgentAggregate,
pub token_rate: f64,
pub interval_ms: u64,
pub sessions: Vec<SessionView>,
pub rate_limits: Vec<RateLimitInfo>,
pub orphan_ports: Vec<OrphanPort>,
pub mcp_servers: Vec<McpServerView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChatMsgView {
pub role: &'static str,
pub text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolCallView {
pub name: String,
pub arg: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SubAgentView {
pub name: String,
pub status: String,
pub tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionView {
pub agent_cli: &'static str,
pub pid: u32,
pub session_id: String,
pub project_name: String,
pub cwd: String,
pub config_root: String,
pub status: SessionStatus,
pub model: String,
pub effort: String,
pub version: String,
pub context_percent: f64,
pub context_window: u64,
pub total_tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_create_tokens: u64,
pub turn_count: u32,
pub mem_mb: u64,
pub git_branch: String,
pub git_added: u32,
pub git_modified: u32,
pub started_at_ms: u64,
pub elapsed_secs: u64,
pub summary: String,
pub current_task: Option<String>,
pub children: Vec<ChildProcess>,
pub compaction_count: u32,
pub token_history: Vec<u64>,
pub subagents: Vec<SubAgentView>,
pub tool_calls: Vec<ToolCallView>,
pub chat_messages: Vec<ChatMsgView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpServerView {
pub pid: u32,
pub parent_cli: &'static str,
pub profile: Option<String>,
pub mem_kb: u64,
pub active_count: usize,
pub rollout_count: usize,
pub last_activity_ms: Option<u64>,
}
fn tail<T: Clone>(v: &[T], n: usize) -> Vec<T> {
if v.len() > n {
v[v.len() - n..].to_vec()
} else {
v.to_vec()
}
}
fn epoch_ms(t: SystemTime) -> Option<u64> {
t.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_millis() as u64)
}
impl App {
pub fn to_snapshot(&self, interval_ms: u64) -> Snapshot {
let now = SystemTime::now();
let sessions = self
.sessions
.iter()
.map(|s| SessionView {
agent_cli: s.agent_cli,
pid: s.pid,
session_id: s.session_id.clone(),
project_name: s.project_name.clone(),
cwd: s.cwd.clone(),
config_root: s.config_root.clone(),
status: s.status.clone(),
model: s.model.clone(),
effort: s.effort.clone(),
version: s.version.clone(),
context_percent: s.context_percent,
context_window: s.context_window,
total_tokens: s.total_tokens(),
input_tokens: s.total_input_tokens,
output_tokens: s.total_output_tokens,
cache_read_tokens: s.total_cache_read,
cache_create_tokens: s.total_cache_create,
turn_count: s.turn_count,
mem_mb: s.mem_mb,
git_branch: s.git_branch.clone(),
git_added: s.git_added,
git_modified: s.git_modified,
started_at_ms: s.started_at,
elapsed_secs: s.elapsed().as_secs(),
summary: self.session_summary(s),
current_task: s.current_tasks.last().cloned(),
children: s.children.clone(),
compaction_count: s.compaction_count,
token_history: tail(&s.token_history, 64),
subagents: tail(&s.subagents, 16)
.iter()
.map(|a| SubAgentView {
name: a.name.clone(),
status: a.status.clone(),
tokens: a.tokens,
})
.collect(),
tool_calls: tail(&s.tool_calls, 24)
.iter()
.map(|t| ToolCallView {
name: t.name.clone(),
arg: t.arg.clone(),
duration_ms: t.duration_ms,
})
.collect(),
chat_messages: tail(&s.chat_messages, MAX_CHAT_MESSAGES)
.iter()
.map(|m| ChatMsgView {
role: match &m.role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
},
text: m.text.clone(),
})
.collect(),
})
.collect();
let mcp_servers = self
.mcp_servers
.iter()
.map(|m| McpServerView {
pid: m.pid,
parent_cli: m.parent_cli,
profile: m.profile.clone(),
mem_kb: m.mem_kb,
active_count: m.active_count(now, ACTIVE_MTIME_SECS),
rollout_count: m.rollouts.len(),
last_activity_ms: m.latest_mtime().and_then(epoch_ms),
})
.collect();
Snapshot {
generated_at_ms: epoch_ms(now).unwrap_or(0),
host: self.host_metrics,
aggregate: self.agent_aggregate,
token_rate: self.token_rates.back().copied().unwrap_or(0.0),
interval_ms,
sessions,
rate_limits: self.rate_limits.clone(),
orphan_ports: self.orphan_ports.clone(),
mcp_servers,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::config::PanelVisibility;
use crate::demo::populate_demo;
use crate::model::SessionStatus;
use crate::theme::Theme;
use std::time::{Duration, UNIX_EPOCH};
fn demo_app() -> App {
let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default());
populate_demo(&mut app);
app
}
#[test]
fn tail_keeps_last_n_and_handles_short_inputs() {
let v = vec![1, 2, 3, 4, 5];
assert_eq!(tail(&v, 2), vec![4, 5]); assert_eq!(tail(&v, 5), vec![1, 2, 3, 4, 5]); assert_eq!(tail(&v, 9), vec![1, 2, 3, 4, 5]); assert_eq!(tail(&v, 0), Vec::<i32>::new()); assert_eq!(tail(&Vec::<i32>::new(), 3), Vec::<i32>::new()); }
#[test]
fn epoch_ms_is_monotonic_and_zero_at_unix_epoch() {
assert_eq!(epoch_ms(UNIX_EPOCH), Some(0));
let later = UNIX_EPOCH + Duration::from_millis(1_500);
assert_eq!(epoch_ms(later), Some(1_500));
}
#[test]
fn session_status_serializes_as_variant_name() {
for (status, wire) in [
(SessionStatus::Thinking, "\"Thinking\""),
(SessionStatus::Executing, "\"Executing\""),
(SessionStatus::Waiting, "\"Waiting\""),
(SessionStatus::Unknown, "\"Unknown\""),
(SessionStatus::RateLimited, "\"RateLimited\""),
(SessionStatus::Done, "\"Done\""),
] {
assert_eq!(serde_json::to_string(&status).unwrap(), wire);
}
}
#[test]
fn to_snapshot_is_a_pure_read() {
let app = demo_app();
let before = app.sessions.len();
let a = app.to_snapshot(2_000);
let b = app.to_snapshot(2_000);
assert_eq!(app.sessions.len(), before);
assert_eq!(a.sessions.len(), b.sessions.len());
assert_eq!(a.sessions.len(), before);
}
#[test]
fn to_snapshot_maps_fields_and_passes_interval_through() {
let app = demo_app();
let snap = app.to_snapshot(1_234);
assert_eq!(snap.interval_ms, 1_234);
assert!(snap.generated_at_ms > 0);
assert!(!snap.sessions.is_empty());
assert!(snap.host.is_some(), "demo populates host metrics");
assert!(!snap.rate_limits.is_empty(), "demo populates rate limits");
for s in &snap.sessions {
assert!(s.token_history.len() <= 64);
assert!(s.tool_calls.len() <= 24);
for m in &s.chat_messages {
assert!(m.role == "user" || m.role == "assistant");
}
}
}
#[test]
fn snapshot_round_trips_through_serde_json() {
let snap = demo_app().to_snapshot(2_000);
let json = serde_json::to_string(&snap).expect("snapshot serializes");
assert!(json.contains("\"sessions\""));
assert!(json.contains("\"interval_ms\":2000"));
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed["sessions"].is_array());
}
#[test]
fn readme_documents_json_snapshot_privacy_surface() {
let readme = include_str!("../README.md");
assert!(readme.contains("--json"));
assert!(readme.contains("JSON snapshot includes"));
assert!(readme.contains("chat_messages"));
assert!(readme.contains("summary"));
}
}