fix(protocol): harden fleet JSON contract

This commit is contained in:
Hunter B
2026-06-12 17:48:59 -07:00
parent 573665b1fe
commit f0ae838eb1
+122 -6
View File
@@ -12,7 +12,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value; use serde_json::Value;
pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0"; pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0";
@@ -101,8 +101,7 @@ pub struct FleetArtifactRef {
} }
/// Kind of artifact a task may produce or consume. /// Kind of artifact a task may produce or consume.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FleetArtifactKind { pub enum FleetArtifactKind {
Log, Log,
Patch, Patch,
@@ -113,6 +112,51 @@ pub enum FleetArtifactKind {
Other(String), Other(String),
} }
impl FleetArtifactKind {
fn as_wire_str(&self) -> &str {
match self {
Self::Log => "log",
Self::Patch => "patch",
Self::TestResult => "test_result",
Self::Report => "report",
Self::Checkpoint => "checkpoint",
Self::Receipt => "receipt",
Self::Other(kind) => kind.as_str(),
}
}
fn from_wire_str(value: &str) -> Self {
match value {
"log" => Self::Log,
"patch" => Self::Patch,
"test_result" => Self::TestResult,
"report" => Self::Report,
"checkpoint" => Self::Checkpoint,
"receipt" => Self::Receipt,
other => Self::Other(other.to_string()),
}
}
}
impl Serialize for FleetArtifactKind {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_wire_str())
}
}
impl<'de> Deserialize<'de> for FleetArtifactKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::from_wire_str(&value))
}
}
/// Scoring rule used to verify a task result. /// Scoring rule used to verify a task result.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
@@ -222,13 +266,16 @@ pub enum FleetWorkerEventPayload {
}, },
Heartbeat { Heartbeat {
#[serde(default)] #[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
cpu_percent: Option<f32>, cpu_percent: Option<f32>,
#[serde(default)] #[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
memory_mb: Option<u64>, memory_mb: Option<u64>,
}, },
Artifact(FleetArtifactRef), Artifact(FleetArtifactRef),
Completed { Completed {
#[serde(default)] #[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>, exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>, summary: Option<String>,
@@ -264,12 +311,13 @@ pub enum FleetWorkerEventPayload {
/// Retry policy for a task or worker. /// Retry policy for a task or worker.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FleetRetryPolicy { pub struct FleetRetryPolicy {
#[serde(default = "default_retry_max_attempts")]
pub max_attempts: u32, pub max_attempts: u32,
#[serde(default)] #[serde(default = "default_retry_initial_backoff_seconds")]
pub initial_backoff_seconds: u64, pub initial_backoff_seconds: u64,
#[serde(default)] #[serde(default = "default_retry_max_backoff_seconds")]
pub max_backoff_seconds: u64, pub max_backoff_seconds: u64,
#[serde(default)] #[serde(default = "default_retry_backoff_multiplier")]
pub backoff_multiplier: u32, pub backoff_multiplier: u32,
} }
@@ -284,6 +332,22 @@ impl Default for FleetRetryPolicy {
} }
} }
fn default_retry_max_attempts() -> u32 {
FleetRetryPolicy::default().max_attempts
}
fn default_retry_initial_backoff_seconds() -> u64 {
FleetRetryPolicy::default().initial_backoff_seconds
}
fn default_retry_max_backoff_seconds() -> u64 {
FleetRetryPolicy::default().max_backoff_seconds
}
fn default_retry_backoff_multiplier() -> u32 {
FleetRetryPolicy::default().backoff_multiplier
}
/// Alert/escalation policy attached to a task or run. /// Alert/escalation policy attached to a task or run.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FleetAlertPolicy { pub struct FleetAlertPolicy {
@@ -455,6 +519,58 @@ mod tests {
assert_eq!(back.size_bytes, Some(1024)); assert_eq!(back.size_bytes, Some(1024));
} }
#[test]
fn artifact_kind_uses_flat_string_json() {
let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
assert_eq!(known, "\"test_result\"");
let custom =
serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
assert_eq!(custom, "\"coverage.xml\"");
let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
}
#[test]
fn retry_policy_missing_fields_use_nonzero_defaults() {
let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(policy, FleetRetryPolicy::default());
let policy: FleetRetryPolicy =
serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
assert_eq!(policy.max_attempts, 5);
assert_eq!(
policy.initial_backoff_seconds,
FleetRetryPolicy::default().initial_backoff_seconds
);
assert_eq!(
policy.max_backoff_seconds,
FleetRetryPolicy::default().max_backoff_seconds
);
assert_eq!(
policy.backoff_multiplier,
FleetRetryPolicy::default().backoff_multiplier
);
}
#[test]
fn sparse_worker_events_omit_absent_optional_fields() {
let heartbeat = FleetWorkerEventPayload::Heartbeat {
cpu_percent: None,
memory_mb: None,
};
let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
let completed = FleetWorkerEventPayload::Completed {
exit_code: None,
summary: None,
};
let completed_json = serde_json::to_value(&completed).unwrap();
assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
}
#[test] #[test]
fn receipt_round_trip() { fn receipt_round_trip() {
let receipt = FleetReceipt { let receipt = FleetReceipt {