1use serde::{Deserialize, Serialize};
8
9use wist_contracts::capability_report::LogsCapabilities;
10
11use super::{AgentLocalWork, AgentUplinkState, HostProfile};
12
13pub const API_VERSION: &str = wist_contracts::API_VERSION_V1;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentWorkState {
19 Paused,
20 Resumed,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct AgentWorkStateChange {
27 pub input_id: String,
28 pub state: AgentWorkState,
29 pub reason: String,
30 pub at: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct AgentStatusReport {
36 pub agent_id: String,
37 pub instance_id: String,
38 pub version: String,
39 #[serde(default)]
41 pub memory_bytes: Option<u64>,
42 #[serde(default)]
46 pub cpu_percent: Option<f64>,
47 #[serde(default)]
58 pub cpu_cores: Option<u32>,
59 #[serde(default)]
61 pub admin_latency_ms: Option<u64>,
62 #[serde(default)]
64 pub work_state_changes: Option<Vec<AgentWorkStateChange>>,
65 #[serde(default)]
71 pub discovery_policy_version: Option<i64>,
72 #[serde(default)]
79 pub local_work: Option<AgentLocalWork>,
80 #[serde(default)]
89 pub uplink_state: Option<AgentUplinkState>,
90 #[serde(default)]
98 pub certificate_status: Option<AgentCertificateStatus>,
99 #[serde(default)]
107 pub machine_profile: Option<HostProfile>,
108 #[serde(default)]
118 pub logs_capabilities: Option<LogsCapabilities>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct AgentCertificateStatus {
125 pub not_after: String,
127 pub remaining_seconds: i64,
129 pub state: String,
131 #[serde(default)]
136 pub last_renewal: Option<AgentCredentialRenewal>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct AgentCredentialRenewal {
146 pub outcome: String,
148 pub checked_at: String,
150 pub detail: String,
152 pub not_after: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct AgentStatusAck {
160 pub agent_id: String,
161 pub instance_id: String,
162 pub acknowledged_at: String,
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{AgentCertificateStatus, AgentStatusReport, AgentWorkState, AgentWorkStateChange};
168
169 #[test]
170 fn agent_work_state_uses_snake_case_on_the_wire() {
171 assert_eq!(
172 serde_json::to_string(&AgentWorkState::Paused).unwrap(),
173 "\"paused\""
174 );
175 assert_eq!(
176 serde_json::to_string(&AgentWorkState::Resumed).unwrap(),
177 "\"resumed\""
178 );
179 let change = AgentWorkStateChange {
180 input_id: "app".to_string(),
181 state: AgentWorkState::Paused,
182 reason: "spool_over_limit".to_string(),
183 at: "2026-09-27T00:00:00Z".to_string(),
184 };
185 let json = serde_json::to_string(&change).expect("encode");
186 let back: AgentWorkStateChange = serde_json::from_str(&json).expect("decode");
187 assert_eq!(back, change);
188
189 let mutated = json.replacen('{', "{\"extra\":1,", 1);
191 assert!(serde_json::from_str::<AgentWorkStateChange>(&mutated).is_err());
192 }
193
194 #[test]
195 fn a_minimal_agent_status_report_decodes_and_extra_keys_fail() {
196 let json = r#"{"agent_id":"a","instance_id":"i","version":"0.1.5"}"#;
198 let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
199 assert_eq!(report.memory_bytes, None);
200 assert_eq!(report.cpu_percent, None);
201 assert_eq!(report.work_state_changes, None);
202 assert_eq!(report.discovery_policy_version, None);
203 assert!(report.local_work.is_none());
204 assert!(report.uplink_state.is_none());
205 assert!(report.certificate_status.is_none());
206 assert!(report.machine_profile.is_none());
208
209 assert!(
210 serde_json::from_str::<AgentStatusReport>(
211 r#"{"agent_id":"a","instance_id":"i","version":"v","nope":1}"#
212 )
213 .is_err()
214 );
215 }
216
217 #[test]
219 fn an_agent_status_report_carries_the_machine_profile() {
220 let json = r#"{
221 "agent_id":"a","instance_id":"i","version":"0.1.24",
222 "machine_profile":{
223 "node_id":"node-1","hostname":"host-1","os":"linux","arch":"x86_64",
224 "machine_id":"mid-1","cloud_instance_id":null,"k8s_node_uid":null,
225 "ip_addresses":["en0 192.168.1.5/24","10.8.0.2"]
226 }
227 }"#;
228 let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
229 let profile = report.machine_profile.expect("machine_profile present");
230 assert_eq!(profile.hostname, "host-1");
231 assert_eq!(profile.node_id, "node-1");
232 assert_eq!(profile.machine_id, "mid-1");
233 assert_eq!(
234 profile.ip_addresses,
235 vec!["en0 192.168.1.5/24".to_string(), "10.8.0.2".to_string()]
236 );
237 }
238
239 #[test]
242 fn certificate_status_last_renewal_is_optional_and_round_trips() {
243 let legacy =
244 r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid"}"#;
245 let status: AgentCertificateStatus = serde_json::from_str(legacy).expect("decode");
246 assert!(status.last_renewal.is_none());
247
248 let json = r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid",
249 "last_renewal":{"outcome":"renewed","checked_at":"2026-10-08T00:00:00Z",
250 "detail":"credential renewed","not_after":"2026-11-04T00:00:00Z"}}"#;
251 let status: AgentCertificateStatus = serde_json::from_str(json).expect("decode");
252 assert_eq!(
253 status.last_renewal.as_ref().expect("renewal").outcome,
254 "renewed"
255 );
256
257 let encoded = serde_json::to_string(&status).expect("encode");
258 let back: AgentCertificateStatus = serde_json::from_str(&encoded).expect("decode");
259 assert_eq!(back, status);
260
261 let drifted = r#"{"not_after":"x","remaining_seconds":0,"state":"valid",
263 "last_renewal":{"outcome":"renewed","checked_at":"t","detail":"","not_after":"",
264 "extra":1}}"#;
265 assert!(serde_json::from_str::<AgentCertificateStatus>(drifted).is_err());
266 }
267
268 #[test]
270 fn logs_capabilities_are_optional_and_round_trip() {
271 let legacy = r#"{"agent_id":"a","instance_id":"i","version":"0.8.0"}"#;
272 let report: AgentStatusReport = serde_json::from_str(legacy).expect("decode");
273 assert!(report.logs_capabilities.is_none());
274
275 let json = r#"{"agent_id":"a","instance_id":"i","version":"0.9.0",
276 "logs_capabilities":{
277 "file_inputs":["absolute-path","glob"],
278 "multiline_modes":["none","indented"],
279 "exporters":["journalctl-kernel"]
280 }}"#;
281 let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
282 let logs = report.logs_capabilities.expect("logs_capabilities");
283 assert_eq!(logs.exporters, vec!["journalctl-kernel".to_string()]);
284 assert!(logs.file_inputs.contains(&"glob".to_string()));
285
286 let drifted = r#"{"agent_id":"a","instance_id":"i","version":"v",
288 "logs_capabilities":{"file_inputs":[],"nope":1}}"#;
289 assert!(serde_json::from_str::<AgentStatusReport>(drifted).is_err());
290 }
291}