use serde::{Deserialize, Serialize};
pub const POLL_AGENT_UPLINK_KIND: &str = "poll_agent_uplink";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Control",
module = "Control.AgentApp.FacingInterface"
)]
#[serde(deny_unknown_fields)]
pub struct PollAgentUplink {
pub api_version: String,
pub kind: String,
pub agent_id: String,
pub instance_id: String,
pub requested_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct AgentUplinkGrant {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub granted_at: String,
}
impl AgentUplinkGrant {
pub fn standby(granted_at: String) -> Self {
Self {
enabled: false,
host: None,
port: None,
granted_at,
}
}
pub fn enabled_at(host: String, port: u16, granted_at: String) -> Self {
Self {
enabled: true,
host: Some(host),
port: Some(port),
granted_at,
}
}
pub fn target(&self) -> Option<(&str, u16)> {
match (self.enabled, self.host.as_deref(), self.port) {
(true, Some(host), Some(port)) => {
let host = host.trim();
(!host.is_empty()).then_some((host, port))
}
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Protocol")]
#[serde(deny_unknown_fields)]
pub struct AgentUplinkState {
pub enabled: bool,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub source: String,
pub output_write_failing: bool,
}
#[cfg(test)]
mod state_tests {
use super::*;
#[test]
fn a_local_file_output_state_omits_the_target() {
let state = AgentUplinkState {
enabled: true,
kind: "file".to_string(),
target: None,
source: "local".to_string(),
output_write_failing: false,
};
let json = serde_json::to_string(&state).expect("encode");
assert!(!json.contains("target"), "{json}");
let back: AgentUplinkState = serde_json::from_str(&json).expect("decode");
assert_eq!(back, state);
}
#[test]
fn a_grant_driven_tcp_state_carries_the_target() {
let state = AgentUplinkState {
enabled: true,
kind: "tcp".to_string(),
target: Some("10.0.1.9:9000".to_string()),
source: "grant".to_string(),
output_write_failing: true,
};
let json = serde_json::to_string(&state).expect("encode");
let back: AgentUplinkState = serde_json::from_str(&json).expect("decode");
assert_eq!(back, state);
assert!(
serde_json::from_str::<AgentUplinkState>(
r#"{"enabled":true,"kind":"tcp",
"source":"local","output_write_failing":false,"extra":1}"#
)
.is_err()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standby_round_trips_and_omits_absent_target() {
let grant = AgentUplinkGrant::standby("2026-09-26T00:00:00Z".to_string());
let json = serde_json::to_string(&grant).expect("encode");
assert!(!json.contains("host"), "{json}");
assert!(!json.contains("port"), "{json}");
let back: AgentUplinkGrant = serde_json::from_str(&json).expect("decode");
assert_eq!(back, grant);
assert_eq!(back.target(), None);
}
#[test]
fn an_enabled_grant_carries_the_target_to_override() {
let grant = AgentUplinkGrant::enabled_at(
"c-001.gateway.example".to_string(),
9000,
"2026-09-26T00:00:00Z".to_string(),
);
assert_eq!(grant.target(), Some(("c-001.gateway.example", 9000)));
let json = serde_json::to_string(&grant).expect("encode");
let back: AgentUplinkGrant = serde_json::from_str(&json).expect("decode");
assert_eq!(back, grant);
}
#[test]
fn an_enabled_grant_without_a_target_falls_back_to_the_local_kind() {
let grant = AgentUplinkGrant {
enabled: true,
host: None,
port: None,
granted_at: "t".to_string(),
};
assert_eq!(grant.target(), None);
let blank = AgentUplinkGrant {
enabled: true,
host: Some(String::new()),
port: Some(9000),
granted_at: "t".to_string(),
};
assert_eq!(blank.target(), None);
let whitespace = AgentUplinkGrant {
enabled: true,
host: Some(" ".to_string()),
port: Some(9000),
granted_at: "t".to_string(),
};
assert_eq!(whitespace.target(), None);
let padded = AgentUplinkGrant {
enabled: true,
host: Some(" gw.example ".to_string()),
port: Some(9000),
granted_at: "t".to_string(),
};
assert_eq!(padded.target(), Some(("gw.example", 9000)));
let host_only = AgentUplinkGrant {
enabled: true,
host: Some("gw.example".to_string()),
port: None,
granted_at: "t".to_string(),
};
assert_eq!(host_only.target(), None);
let standby_with_target = AgentUplinkGrant {
enabled: false,
host: Some("gw.example".to_string()),
port: Some(9000),
granted_at: "t".to_string(),
};
assert_eq!(standby_with_target.target(), None);
}
#[test]
fn a_newer_gateway_field_is_rejected_rather_than_ignored() {
let json = r#"{"enabled":true,"granted_at":"t","extra":1}"#;
assert!(serde_json::from_str::<AgentUplinkGrant>(json).is_err());
}
#[test]
fn poll_round_trips_and_rejects_unknown_fields() {
let poll = PollAgentUplink {
api_version: crate::API_VERSION_V1.to_string(),
kind: POLL_AGENT_UPLINK_KIND.to_string(),
agent_id: "agent-1".to_string(),
instance_id: "inst-1".to_string(),
requested_at: "2026-09-26T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&poll).expect("encode");
let back: PollAgentUplink = serde_json::from_str(&json).expect("decode");
assert_eq!(back, poll);
let bad = r#"{"api_version":"v1","kind":"poll_agent_uplink","agent_id":"a",
"instance_id":"i","requested_at":"t","extra":1}"#;
assert!(serde_json::from_str::<PollAgentUplink>(bad).is_err());
}
}