1use serde::{Deserialize, Serialize};
8
9use super::{AgentLocalWork, AgentUplinkState, HostProfile};
10
11pub const API_VERSION: &str = wist_contracts::API_VERSION_V1;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AgentWorkState {
17 Paused,
18 Resumed,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct AgentWorkStateChange {
25 pub input_id: String,
26 pub state: AgentWorkState,
27 pub reason: String,
28 pub at: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct AgentStatusReport {
34 pub agent_id: String,
35 pub instance_id: String,
36 pub version: String,
37 #[serde(default)]
39 pub memory_bytes: Option<u64>,
40 #[serde(default)]
44 pub cpu_percent: Option<f64>,
45 #[serde(default)]
56 pub cpu_cores: Option<u32>,
57 #[serde(default)]
59 pub admin_latency_ms: Option<u64>,
60 #[serde(default)]
62 pub work_state_changes: Option<Vec<AgentWorkStateChange>>,
63 #[serde(default)]
69 pub discovery_policy_version: Option<i64>,
70 #[serde(default)]
77 pub local_work: Option<AgentLocalWork>,
78 #[serde(default)]
87 pub uplink_state: Option<AgentUplinkState>,
88 #[serde(default)]
96 pub certificate_status: Option<AgentCertificateStatus>,
97 #[serde(default)]
105 pub machine_profile: Option<HostProfile>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct AgentCertificateStatus {
112 pub not_after: String,
114 pub remaining_seconds: i64,
116 pub state: String,
118 #[serde(default)]
123 pub last_renewal: Option<AgentCredentialRenewal>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct AgentCredentialRenewal {
133 pub outcome: String,
135 pub checked_at: String,
137 pub detail: String,
139 pub not_after: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct AgentStatusAck {
147 pub agent_id: String,
148 pub instance_id: String,
149 pub acknowledged_at: String,
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{AgentCertificateStatus, AgentStatusReport, AgentWorkState, AgentWorkStateChange};
155
156 #[test]
157 fn agent_work_state_uses_snake_case_on_the_wire() {
158 assert_eq!(
159 serde_json::to_string(&AgentWorkState::Paused).unwrap(),
160 "\"paused\""
161 );
162 assert_eq!(
163 serde_json::to_string(&AgentWorkState::Resumed).unwrap(),
164 "\"resumed\""
165 );
166 let change = AgentWorkStateChange {
167 input_id: "app".to_string(),
168 state: AgentWorkState::Paused,
169 reason: "spool_over_limit".to_string(),
170 at: "2026-09-27T00:00:00Z".to_string(),
171 };
172 let json = serde_json::to_string(&change).expect("encode");
173 let back: AgentWorkStateChange = serde_json::from_str(&json).expect("decode");
174 assert_eq!(back, change);
175
176 let mutated = json.replacen('{', "{\"extra\":1,", 1);
178 assert!(serde_json::from_str::<AgentWorkStateChange>(&mutated).is_err());
179 }
180
181 #[test]
182 fn a_minimal_agent_status_report_decodes_and_extra_keys_fail() {
183 let json = r#"{"agent_id":"a","instance_id":"i","version":"0.1.5"}"#;
185 let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
186 assert_eq!(report.memory_bytes, None);
187 assert_eq!(report.cpu_percent, None);
188 assert_eq!(report.work_state_changes, None);
189 assert_eq!(report.discovery_policy_version, None);
190 assert!(report.local_work.is_none());
191 assert!(report.uplink_state.is_none());
192 assert!(report.certificate_status.is_none());
193 assert!(report.machine_profile.is_none());
195
196 assert!(
197 serde_json::from_str::<AgentStatusReport>(
198 r#"{"agent_id":"a","instance_id":"i","version":"v","nope":1}"#
199 )
200 .is_err()
201 );
202 }
203
204 #[test]
206 fn an_agent_status_report_carries_the_machine_profile() {
207 let json = r#"{
208 "agent_id":"a","instance_id":"i","version":"0.1.24",
209 "machine_profile":{
210 "node_id":"node-1","hostname":"host-1","os":"linux","arch":"x86_64",
211 "machine_id":"mid-1","cloud_instance_id":null,"k8s_node_uid":null,
212 "ip_addresses":["en0 192.168.1.5/24","10.8.0.2"]
213 }
214 }"#;
215 let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
216 let profile = report.machine_profile.expect("machine_profile present");
217 assert_eq!(profile.hostname, "host-1");
218 assert_eq!(profile.node_id, "node-1");
219 assert_eq!(profile.machine_id, "mid-1");
220 assert_eq!(
221 profile.ip_addresses,
222 vec!["en0 192.168.1.5/24".to_string(), "10.8.0.2".to_string()]
223 );
224 }
225
226 #[test]
229 fn certificate_status_last_renewal_is_optional_and_round_trips() {
230 let legacy =
231 r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid"}"#;
232 let status: AgentCertificateStatus = serde_json::from_str(legacy).expect("decode");
233 assert!(status.last_renewal.is_none());
234
235 let json = r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid",
236 "last_renewal":{"outcome":"renewed","checked_at":"2026-10-08T00:00:00Z",
237 "detail":"credential renewed","not_after":"2026-11-04T00:00:00Z"}}"#;
238 let status: AgentCertificateStatus = serde_json::from_str(json).expect("decode");
239 assert_eq!(
240 status.last_renewal.as_ref().expect("renewal").outcome,
241 "renewed"
242 );
243
244 let encoded = serde_json::to_string(&status).expect("encode");
245 let back: AgentCertificateStatus = serde_json::from_str(&encoded).expect("decode");
246 assert_eq!(back, status);
247
248 let drifted = r#"{"not_after":"x","remaining_seconds":0,"state":"valid",
250 "last_renewal":{"outcome":"renewed","checked_at":"t","detail":"","not_after":"",
251 "extra":1}}"#;
252 assert!(serde_json::from_str::<AgentCertificateStatus>(drifted).is_err());
253 }
254}