use serde::{Deserialize, Serialize};
use super::{AgentLocalWork, AgentUplinkState, HostProfile};
pub const API_VERSION: &str = wist_contracts::API_VERSION_V1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentWorkState {
Paused,
Resumed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentWorkStateChange {
pub input_id: String,
pub state: AgentWorkState,
pub reason: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentStatusReport {
pub agent_id: String,
pub instance_id: String,
pub version: String,
#[serde(default)]
pub memory_bytes: Option<u64>,
#[serde(default)]
pub cpu_percent: Option<f64>,
#[serde(default)]
pub cpu_cores: Option<u32>,
#[serde(default)]
pub admin_latency_ms: Option<u64>,
#[serde(default)]
pub work_state_changes: Option<Vec<AgentWorkStateChange>>,
#[serde(default)]
pub discovery_policy_version: Option<i64>,
#[serde(default)]
pub local_work: Option<AgentLocalWork>,
#[serde(default)]
pub uplink_state: Option<AgentUplinkState>,
#[serde(default)]
pub certificate_status: Option<AgentCertificateStatus>,
#[serde(default)]
pub machine_profile: Option<HostProfile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentCertificateStatus {
pub not_after: String,
pub remaining_seconds: i64,
pub state: String,
#[serde(default)]
pub last_renewal: Option<AgentCredentialRenewal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentCredentialRenewal {
pub outcome: String,
pub checked_at: String,
pub detail: String,
pub not_after: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentStatusAck {
pub agent_id: String,
pub instance_id: String,
pub acknowledged_at: String,
}
#[cfg(test)]
mod tests {
use super::{AgentCertificateStatus, AgentStatusReport, AgentWorkState, AgentWorkStateChange};
#[test]
fn agent_work_state_uses_snake_case_on_the_wire() {
assert_eq!(
serde_json::to_string(&AgentWorkState::Paused).unwrap(),
"\"paused\""
);
assert_eq!(
serde_json::to_string(&AgentWorkState::Resumed).unwrap(),
"\"resumed\""
);
let change = AgentWorkStateChange {
input_id: "app".to_string(),
state: AgentWorkState::Paused,
reason: "spool_over_limit".to_string(),
at: "2026-09-27T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&change).expect("encode");
let back: AgentWorkStateChange = serde_json::from_str(&json).expect("decode");
assert_eq!(back, change);
let mutated = json.replacen('{', "{\"extra\":1,", 1);
assert!(serde_json::from_str::<AgentWorkStateChange>(&mutated).is_err());
}
#[test]
fn a_minimal_agent_status_report_decodes_and_extra_keys_fail() {
let json = r#"{"agent_id":"a","instance_id":"i","version":"0.1.5"}"#;
let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
assert_eq!(report.memory_bytes, None);
assert_eq!(report.cpu_percent, None);
assert_eq!(report.work_state_changes, None);
assert_eq!(report.discovery_policy_version, None);
assert!(report.local_work.is_none());
assert!(report.uplink_state.is_none());
assert!(report.certificate_status.is_none());
assert!(report.machine_profile.is_none());
assert!(
serde_json::from_str::<AgentStatusReport>(
r#"{"agent_id":"a","instance_id":"i","version":"v","nope":1}"#
)
.is_err()
);
}
#[test]
fn an_agent_status_report_carries_the_machine_profile() {
let json = r#"{
"agent_id":"a","instance_id":"i","version":"0.1.24",
"machine_profile":{
"node_id":"node-1","hostname":"host-1","os":"linux","arch":"x86_64",
"machine_id":"mid-1","cloud_instance_id":null,"k8s_node_uid":null,
"ip_addresses":["en0 192.168.1.5/24","10.8.0.2"]
}
}"#;
let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
let profile = report.machine_profile.expect("machine_profile present");
assert_eq!(profile.hostname, "host-1");
assert_eq!(profile.node_id, "node-1");
assert_eq!(profile.machine_id, "mid-1");
assert_eq!(
profile.ip_addresses,
vec!["en0 192.168.1.5/24".to_string(), "10.8.0.2".to_string()]
);
}
#[test]
fn certificate_status_last_renewal_is_optional_and_round_trips() {
let legacy =
r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid"}"#;
let status: AgentCertificateStatus = serde_json::from_str(legacy).expect("decode");
assert!(status.last_renewal.is_none());
let json = r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid",
"last_renewal":{"outcome":"renewed","checked_at":"2026-10-08T00:00:00Z",
"detail":"credential renewed","not_after":"2026-11-04T00:00:00Z"}}"#;
let status: AgentCertificateStatus = serde_json::from_str(json).expect("decode");
assert_eq!(
status.last_renewal.as_ref().expect("renewal").outcome,
"renewed"
);
let encoded = serde_json::to_string(&status).expect("encode");
let back: AgentCertificateStatus = serde_json::from_str(&encoded).expect("decode");
assert_eq!(back, status);
let drifted = r#"{"not_after":"x","remaining_seconds":0,"state":"valid",
"last_renewal":{"outcome":"renewed","checked_at":"t","detail":"","not_after":"",
"extra":1}}"#;
assert!(serde_json::from_str::<AgentCertificateStatus>(drifted).is_err());
}
}