From cf3ac49729719e0d7ad8c671be62fb7d46f45baa Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 2 Jul 2026 21:11:00 +0200 Subject: [PATCH 1/4] hive-sh4re: wire_time serde adaptor - timestamps as rfc3339 on the wire --- Cargo.lock | 2 + Cargo.toml | 1 + hive-sh4re/Cargo.toml | 4 + hive-sh4re/src/lib.rs | 35 +++++++-- hive-sh4re/src/wire_time.rs | 144 ++++++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 hive-sh4re/src/wire_time.rs 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); + } +} From bac2c0a65ec7518aca61862066bbb48d855be83a Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 2 Jul 2026 21:30:16 +0200 Subject: [PATCH 2/4] hive-c0re: rfc3339 timestamps on dashboard api + sse json --- hive-c0re/src/audit_log.rs | 1 + hive-c0re/src/broker.rs | 2 ++ hive-c0re/src/dashboard.rs | 11 +++++++---- hive-c0re/src/dashboard_events.rs | 6 ++++++ hive-c0re/src/operator_questions.rs | 9 ++++++--- hive-sh4re/src/wire_time.rs | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/hive-c0re/src/audit_log.rs b/hive-c0re/src/audit_log.rs index 4a64efa7..43f1772a 100644 --- a/hive-c0re/src/audit_log.rs +++ b/hive-c0re/src/audit_log.rs @@ -89,6 +89,7 @@ impl AuditOutcome { #[derive(Debug, Clone, Serialize)] pub struct AuditEntry { pub id: i64, + #[serde(with = "hive_sh4re::wire_time::iso")] pub ts_unix: i64, /// Agent on whose behalf the action was taken. pub agent: String, diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index b2caef09..aedf314f 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -74,7 +74,9 @@ pub struct PendingReminder { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, + #[serde(with = "hive_sh4re::wire_time::iso")] pub due_at: i64, + #[serde(with = "hive_sh4re::wire_time::iso")] pub created_at: i64, /// Most recent delivery failure for this row, if any. Cleared /// to NULL on operator retry. Surfaced inline in the dashboard diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 61d8dd49..5a0399e7 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -432,7 +432,8 @@ struct ApprovalHistoryView { sha_short: Option, /// `approved` / `denied` / `failed`. status: &'static str, - /// Unix seconds. Renders as a relative time on the dashboard. + /// RFC 3339 UTC. Renders as a relative time on the dashboard. + #[serde(with = "hive_sh4re::wire_time::iso")] resolved_at: i64, /// Operator-supplied deny reason (for `denied`) or build error /// (for `failed`). None on `approved`. @@ -471,8 +472,10 @@ struct ApprovalView { /// `None` for every other kind. #[serde(skip_serializing_if = "Option::is_none")] commit_ref: Option, - /// Unix seconds the approval was queued. Rendered as a relative - /// time on the card so the operator can spot a stale request. + /// RFC 3339 UTC time the approval was queued. Rendered as a + /// relative time on the card so the operator can spot a stale + /// request. + #[serde(with = "hive_sh4re::wire_time::iso")] requested_at: i64, } @@ -1373,7 +1376,7 @@ async fn api_operator_inbox(State(state): State) -> Response { "id": id, "from": from, "body": body, - "at": at, + "at": hive_sh4re::wire_time::to_iso(at), "in_reply_to": in_reply_to, "file_refs": file_refs, })) diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 13307bcb..48610473 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -38,6 +38,7 @@ pub enum DashboardEvent { from: String, to: String, body: String, + #[serde(with = "hive_sh4re::wire_time::iso")] at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, @@ -53,6 +54,7 @@ pub enum DashboardEvent { from: String, to: String, body: String, + #[serde(with = "hive_sh4re::wire_time::iso")] at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, @@ -95,6 +97,7 @@ pub enum DashboardEvent { sha_short: Option, /// `"approved"` / `"denied"` / `"failed"`. status: &'static str, + #[serde(with = "hive_sh4re::wire_time::iso")] resolved_at: i64, note: Option, description: Option, @@ -111,7 +114,9 @@ pub enum DashboardEvent { question: String, options: Vec, multi: bool, + #[serde(with = "hive_sh4re::wire_time::iso")] asked_at: i64, + #[serde(with = "hive_sh4re::wire_time::iso_opt")] deadline_at: Option, target: Option, /// Verified file-path tokens that appear in `question`. @@ -130,6 +135,7 @@ pub enum DashboardEvent { id: i64, answer: String, answerer: String, + #[serde(with = "hive_sh4re::wire_time::iso")] answered_at: i64, cancelled: bool, target: Option, diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 1dcf14ad..ab51e9f0 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -74,11 +74,14 @@ pub struct OpQuestion { pub question: String, pub options: Vec, pub multi: bool, + #[serde(with = "hive_sh4re::wire_time::iso")] pub asked_at: i64, - /// Absolute unix-seconds deadline after which a watchdog auto- - /// resolves the question with answer `[expired]`. `None` = no - /// expiry. Surfaced on the dashboard as a remaining-time chip. + /// Deadline after which a watchdog auto-resolves the question with + /// answer `[expired]`. `None` = no expiry. Surfaced on the + /// dashboard as a remaining-time chip. + #[serde(with = "hive_sh4re::wire_time::iso_opt")] pub deadline_at: Option, + #[serde(with = "hive_sh4re::wire_time::iso_opt")] pub answered_at: Option, pub answer: Option, /// Recipient of the question. `None` = the operator (dashboard diff --git a/hive-sh4re/src/wire_time.rs b/hive-sh4re/src/wire_time.rs index 680eaa87..cbc3dae7 100644 --- a/hive-sh4re/src/wire_time.rs +++ b/hive-sh4re/src/wire_time.rs @@ -16,7 +16,7 @@ //! (keep the usual `default` + `skip_serializing_if` attributes). use chrono::{DateTime, SecondsFormat, Utc}; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; /// 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 From cafef519a99d43da758e72cf2264f2812e26be79 Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 2 Jul 2026 21:35:03 +0200 Subject: [PATCH 3/4] dashboard frontend: consume rfc3339 timestamps from the api --- frontend/packages/dashboard/src/call.js | 19 +++++++------- frontend/packages/dashboard/src/flow.js | 7 +++--- frontend/packages/dashboard/src/logs.js | 12 +++++---- frontend/packages/dashboard/src/schedules.js | 26 ++++++++++---------- frontend/packages/dashboard/src/util.js | 15 ++++++++--- 5 files changed, 46 insertions(+), 33 deletions(-) diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 2440c6df..a332d20e 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -16,7 +16,7 @@ import { $, el, form, Panel, appendLinkified } from './common.js'; import { themedToast } from './modal.js'; -import { fmtAgo, fmtDuration } from './util.js'; +import { epochSec, fmtAgo, fmtDuration } from './util.js'; import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js'; // Registered by the dashboard entry at boot; defaults to a no-op so the @@ -70,7 +70,7 @@ function renderOperatorInbox() { `✓ mark all read (${operatorInbox.length})`); mark.addEventListener('click', markOperatorInboxRead); root.append(el('div', { class: 'inbox-toolbar' }, mark)); - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'inbox' }); for (const m of operatorInbox) { const body = el('span', { class: 'msg-body' }); @@ -333,11 +333,12 @@ export function renderApprovals() { // Goes amber once it's been pending an hour so a stale request is // obvious at a glance (see docs/web-ui.md::Approval card). if (a.requested_at != null) { - const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at)); + const requestedSec = epochSec(a.requested_at); + const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec)); head.append(el('span', { class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), - title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), - 'data-requested-at': String(a.requested_at), + title: 'requested ' + new Date(a.requested_at).toLocaleString(), + 'data-requested-at': String(requestedSec), }, 'requested ' + fmtAgo(a.requested_at))); } li.append(head); @@ -556,7 +557,7 @@ function questionRowFingerprint(q) { // Event listeners attached here (keydown on textarea, submit on form) are // preserved in the reused node — no re-attachment needed. function buildQuestionLi(q) { - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); const targetLabel = q.target || 'operator'; const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); const head = el('div', { class: 'q-head' }, @@ -570,10 +571,10 @@ function buildQuestionLi(q) { // Tag the chip with its deadline so the global 1s ticker // can refresh the text without re-rendering the questions section. const ttlEl = el('span', { - class: 'q-ttl', 'data-deadline': String(q.deadline_at), + class: 'q-ttl', 'data-deadline': String(epochSec(q.deadline_at)), }); ttlEl.textContent = formatTtl( - q.deadline_at - Math.floor(Date.now() / 1000), + epochSec(q.deadline_at) - Math.floor(Date.now() / 1000), ); head.append(' ', ttlEl); } @@ -694,7 +695,7 @@ export function renderQuestions() { //
  • nodes (preserving textarea/checkbox state) and only rebuilds // cache-miss rows, so we no longer wipe the DOM at the start. const openDetails = snapshotOpenDetails(root); - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); const allPending = questionsState.pending; // Filter chips. Always include `all` / `operator` / `peer`; add diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index d8cc681e..94ab12f4 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -18,6 +18,7 @@ import { appendLinkified, openStream, initServerWarnings, } from './common.js'; +import { epochSec } from './util.js'; (() => { NOTIF.bind(); @@ -92,7 +93,7 @@ import { const flow = $('msgflow'); if (!flow) return; flow.replaceChildren(); - const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19); + const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19); // Pulse the page banner whenever a broker event lands. The // `.banner` element lives in the dashboard's