diff --git a/Cargo.lock b/Cargo.lock index ad94e4ad..927cc4c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,8 +1439,10 @@ dependencies = [ name = "hive-sh4re" version = "0.1.0" dependencies = [ + "chrono", "schemars", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44680642..70e52393 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ libc = "0.2" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" bcrypt = "0.19" +chrono = { version = "0.4", default-features = false, features = ["std"] } clap = { version = "4", features = ["derive"] } clap_complete = "4" hive-sh4re = { path = "hive-sh4re" } diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index d9e31bec..613b0fa7 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -7,5 +7,9 @@ version.workspace = true workspace = true [dependencies] +chrono.workspace = true schemars.workspace = true serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index f9b5332d..3913e120 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub mod assets; pub mod paths; pub mod priv_proto; +pub mod wire_time; // ----------------------------------------------------------------------------- // Host admin socket — /run/hyperhive/host.sock @@ -198,9 +199,14 @@ pub struct Approval { /// hive-c0re refreshes this + re-renders the card for re-review. #[serde(default, skip_serializing_if = "Option::is_none")] pub fetched_sha: Option, + #[serde(with = "crate::wire_time::iso")] pub requested_at: i64, pub status: ApprovalStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] pub resolved_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, @@ -437,6 +443,7 @@ pub enum LooseEnd { id: i64, owner: String, message: String, + #[serde(with = "crate::wire_time::iso")] due_at: i64, age_seconds: u64, }, @@ -1402,16 +1409,26 @@ pub struct WireSchedule { pub body: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub interval_seconds: Option, + #[serde(with = "crate::wire_time::iso")] pub next_fire_at_unix: i64, + #[serde(with = "crate::wire_time::iso")] pub created_at_unix: i64, pub source: WireScheduleSource, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] pub cancelled_at_unix: Option, /// Set while the schedule is paused. Worker skips paused rows; /// they keep their `next_fire_at_unix` so resuming at any time /// fires at the next intended instant (no catch-up clamp needed /// — a paused schedule simply slips its next fire). - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] pub paused_at_unix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -1428,9 +1445,17 @@ pub enum WireScheduleSource { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WireScheduleTarget { pub target: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] pub cancelled_at_unix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] pub last_fired_at_unix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_result: Option, diff --git a/hive-sh4re/src/wire_time.rs b/hive-sh4re/src/wire_time.rs new file mode 100644 index 00000000..680eaa87 --- /dev/null +++ b/hive-sh4re/src/wire_time.rs @@ -0,0 +1,144 @@ +//! Serde adaptors for timestamp fields: `i64` unix-epoch seconds in +//! Rust, RFC 3339 UTC strings (`2026-07-02T18:30:00Z`) in JSON. +//! +//! Rust code keeps doing plain integer arithmetic on these fields — +//! only the serialized representation changes, so the dashboard (and +//! any other JSON consumer) can feed the value straight into +//! `new Date(s)` without the `* 1000` epoch dance. +//! +//! Deserialization is lenient: both the RFC 3339 string form and the +//! legacy bare-integer form are accepted. That keeps a rolling deploy +//! safe (an old peer emitting epoch ints into a new reader) and lets +//! previously persisted JSON blobs re-load unchanged. +//! +//! Usage: `#[serde(with = "crate::wire_time::iso")]` on `i64` fields, +//! `#[serde(with = "crate::wire_time::iso_opt")]` on `Option` +//! (keep the usual `default` + `skip_serializing_if` attributes). + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Deserializer}; + +/// Format unix-epoch seconds as an RFC 3339 UTC string with a `Z` +/// suffix. Out-of-range values (never produced by our clocks) clamp to +/// the epoch rather than erroring — serialization must not fail. +#[must_use] +pub fn to_iso(secs: i64) -> String { + DateTime::::from_timestamp(secs, 0) + .unwrap_or_default() + .to_rfc3339_opts(SecondsFormat::Secs, true) +} + +/// Parse an RFC 3339 string back to unix-epoch seconds. Any UTC offset +/// is accepted and normalized. +pub fn from_iso(s: &str) -> Result { + Ok(DateTime::parse_from_rfc3339(s)?.timestamp()) +} + +/// Lenient wire form: either the legacy epoch integer or the RFC 3339 +/// string. `untagged` tries the integer first (cheap), then the string. +#[derive(Deserialize)] +#[serde(untagged)] +enum EpochOrIso { + Epoch(i64), + Iso(String), +} + +impl EpochOrIso { + fn into_secs(self) -> Result { + match self { + Self::Epoch(secs) => Ok(secs), + Self::Iso(s) => from_iso(&s).map_err(E::custom), + } + } +} + +/// Adaptor for required `i64` timestamp fields. +pub mod iso { + use serde::{Deserializer, Serializer}; + + use super::{Deserialize, EpochOrIso}; + + pub fn serialize(secs: &i64, ser: S) -> Result { + ser.serialize_str(&super::to_iso(*secs)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { + EpochOrIso::deserialize(de)?.into_secs() + } +} + +/// Adaptor for `Option` timestamp fields. +pub mod iso_opt { + use serde::{Deserializer, Serializer}; + + use super::{Deserialize, EpochOrIso}; + + pub fn serialize(secs: &Option, ser: S) -> Result { + match secs { + Some(secs) => ser.serialize_str(&super::to_iso(*secs)), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + Option::::deserialize(de)? + .map(EpochOrIso::into_secs) + .transpose() + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Row { + #[serde(with = "crate::wire_time::iso")] + at: i64, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::wire_time::iso_opt" + )] + maybe_at: Option, + } + + #[test] + fn serializes_epoch_as_rfc3339_z() { + let json = serde_json::to_string(&Row { + at: 1_751_480_000, + maybe_at: None, + }) + .unwrap(); + assert_eq!(json, r#"{"at":"2025-07-02T18:13:20Z"}"#); + } + + #[test] + fn round_trips_and_serializes_some() { + let row = Row { + at: 0, + maybe_at: Some(1_751_480_000), + }; + let json = serde_json::to_string(&row).unwrap(); + assert_eq!( + json, + r#"{"at":"1970-01-01T00:00:00Z","maybe_at":"2025-07-02T18:13:20Z"}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), row); + } + + #[test] + fn deserializes_legacy_epoch_ints() { + // Rolling-deploy skew: an old writer still emits bare epoch + // integers — the lenient reader must accept them. + let row: Row = serde_json::from_str(r#"{"at":1751480000,"maybe_at":1751480000}"#).unwrap(); + assert_eq!(row.at, 1_751_480_000); + assert_eq!(row.maybe_at, Some(1_751_480_000)); + } + + #[test] + fn deserializes_offset_form_normalized_to_utc() { + let row: Row = serde_json::from_str(r#"{"at":"2025-07-02T20:13:20+02:00"}"#).unwrap(); + assert_eq!(row.at, 1_751_480_000); + } +}