hive-sh4re: wire_time serde adaptor - timestamps as rfc3339 on the wire

This commit is contained in:
damocles 2026-07-02 21:11:00 +02:00 committed by mara
commit cf3ac49729
5 changed files with 181 additions and 5 deletions

2
Cargo.lock generated
View file

@ -1439,8 +1439,10 @@ dependencies = [
name = "hive-sh4re"
version = "0.1.0"
dependencies = [
"chrono",
"schemars",
"serde",
"serde_json",
]
[[package]]

View file

@ -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" }

View file

@ -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

View file

@ -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<String>,
#[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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
@ -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<u64>,
#[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<i64>,
/// 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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
@ -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<i64>,
#[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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>,

144
hive-sh4re/src/wire_time.rs Normal file
View file

@ -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<i64>`
//! (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::<Utc>::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<i64, chrono::ParseError> {
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<E: serde::de::Error>(self) -> Result<i64, E> {
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<S: Serializer>(secs: &i64, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&super::to_iso(*secs))
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<i64, D::Error> {
EpochOrIso::deserialize(de)?.into_secs()
}
}
/// Adaptor for `Option<i64>` timestamp fields.
pub mod iso_opt {
use serde::{Deserializer, Serializer};
use super::{Deserialize, EpochOrIso};
pub fn serialize<S: Serializer>(secs: &Option<i64>, ser: S) -> Result<S::Ok, S::Error> {
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<Option<i64>, D::Error> {
Option::<EpochOrIso>::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<i64>,
}
#[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::<Row>(&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);
}
}