wire types: WireTime newtype for timestamps instead of adaptor-annotated i64

This commit is contained in:
damocles 2026-07-03 19:18:33 +02:00 committed by mara
commit 1e205289c5
12 changed files with 148 additions and 162 deletions

View file

@ -231,9 +231,9 @@ impl Approvals {
agent: row.agent, agent: row.agent,
kind: kind_from_str(&row.kind)?, kind: kind_from_str(&row.kind)?,
commit_ref: row.commit_ref, commit_ref: row.commit_ref,
requested_at: row.requested_at, requested_at: hive_sh4re::wire_time::WireTime(row.requested_at),
status: ApprovalStatus::Approved, status: ApprovalStatus::Approved,
resolved_at: Some(resolved_at), resolved_at: Some(hive_sh4re::wire_time::WireTime(resolved_at)),
note: None, note: None,
fetched_sha: row.fetched_sha, fetched_sha: row.fetched_sha,
description: row.description, description: row.description,
@ -294,9 +294,9 @@ impl Approvals {
agent: row.agent, agent: row.agent,
kind: kind_from_str(&row.kind)?, kind: kind_from_str(&row.kind)?,
commit_ref: row.commit_ref, commit_ref: row.commit_ref,
requested_at: row.requested_at, requested_at: hive_sh4re::wire_time::WireTime(row.requested_at),
status: ApprovalStatus::Cancelled, status: ApprovalStatus::Cancelled,
resolved_at: Some(resolved_at), resolved_at: Some(hive_sh4re::wire_time::WireTime(resolved_at)),
note: Some(note), note: Some(note),
fetched_sha: row.fetched_sha, fetched_sha: row.fetched_sha,
description: row.description, description: row.description,
@ -404,9 +404,11 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
agent: row.get(1)?, agent: row.get(1)?,
kind, kind,
commit_ref: row.get(3)?, commit_ref: row.get(3)?,
requested_at: row.get(4)?, requested_at: hive_sh4re::wire_time::WireTime(row.get(4)?),
status, status,
resolved_at: row.get(6)?, resolved_at: row
.get::<_, Option<i64>>(6)?
.map(hive_sh4re::wire_time::WireTime),
note: row.get(7)?, note: row.get(7)?,
fetched_sha: row.get(8)?, fetched_sha: row.get(8)?,
description: row.get(9)?, description: row.get(9)?,

View file

@ -24,6 +24,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hive_sh4re::wire_time::WireTime;
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
@ -89,8 +90,7 @@ impl AuditOutcome {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct AuditEntry { pub struct AuditEntry {
pub id: i64, pub id: i64,
#[serde(with = "hive_sh4re::wire_time::iso")] pub ts_unix: WireTime,
pub ts_unix: i64,
/// Agent on whose behalf the action was taken. /// Agent on whose behalf the action was taken.
pub agent: String, pub agent: String,
/// What was done (e.g. `restart_infra`). /// What was done (e.g. `restart_infra`).
@ -159,7 +159,7 @@ impl AuditLog {
) { ) {
Ok(_) => Some(AuditEntry { Ok(_) => Some(AuditEntry {
id: conn.last_insert_rowid(), id: conn.last_insert_rowid(),
ts_unix: now, ts_unix: hive_sh4re::wire_time::WireTime(now),
agent: agent.to_owned(), agent: agent.to_owned(),
action: action.to_owned(), action: action.to_owned(),
target: target.to_owned(), target: target.to_owned(),
@ -252,7 +252,7 @@ pub fn spawn_vacuum(coord: &Arc<crate::coordinator::Coordinator>) {
fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result<AuditEntry> { fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result<AuditEntry> {
Ok(AuditEntry { Ok(AuditEntry {
id: r.get(0)?, id: r.get(0)?,
ts_unix: r.get(1)?, ts_unix: hive_sh4re::wire_time::WireTime(r.get(1)?),
agent: r.get(2)?, agent: r.get(2)?,
action: r.get(3)?, action: r.get(3)?,
target: r.get(4)?, target: r.get(4)?,

View file

@ -7,6 +7,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hive_sh4re::wire_time::WireTime;
use hive_sh4re::{InboxRow, Message}; use hive_sh4re::{InboxRow, Message};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
@ -74,10 +75,8 @@ pub struct PendingReminder {
pub message: String, pub message: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>, pub file_path: Option<String>,
#[serde(with = "hive_sh4re::wire_time::iso")] pub due_at: WireTime,
pub due_at: i64, pub created_at: WireTime,
#[serde(with = "hive_sh4re::wire_time::iso")]
pub created_at: i64,
/// Most recent delivery failure for this row, if any. Cleared /// Most recent delivery failure for this row, if any. Cleared
/// to NULL on operator retry. Surfaced inline in the dashboard /// to NULL on operator retry. Surfaced inline in the dashboard
/// so a stuck reminder doesn't just silently retry forever. /// so a stuck reminder doesn't just silently retry forever.
@ -926,8 +925,8 @@ impl Broker {
agent: row.get(1)?, agent: row.get(1)?,
message: row.get(2)?, message: row.get(2)?,
file_path: row.get(3)?, file_path: row.get(3)?,
due_at: row.get(4)?, due_at: hive_sh4re::wire_time::WireTime(row.get(4)?),
created_at: row.get(5)?, created_at: hive_sh4re::wire_time::WireTime(row.get(5)?),
last_error: row.get(6)?, last_error: row.get(6)?,
attempt_count: u32::try_from(attempts).unwrap_or(0), attempt_count: u32::try_from(attempts).unwrap_or(0),
}) })

View file

@ -805,7 +805,7 @@ impl Coordinator {
approval_kind, approval_kind,
sha_short, sha_short,
status, status,
resolved_at, resolved_at: hive_sh4re::wire_time::WireTime(resolved_at),
note, note,
description, description,
}); });
@ -838,8 +838,8 @@ impl Coordinator {
question: question.to_owned(), question: question.to_owned(),
options: options.to_vec(), options: options.to_vec(),
multi, multi,
asked_at, asked_at: hive_sh4re::wire_time::WireTime(asked_at),
deadline_at, deadline_at: deadline_at.map(hive_sh4re::wire_time::WireTime),
target: target.map(str::to_owned), target: target.map(str::to_owned),
question_refs, question_refs,
}); });
@ -869,7 +869,7 @@ impl Coordinator {
id, id,
answer: answer.to_owned(), answer: answer.to_owned(),
answerer: answerer.to_owned(), answerer: answerer.to_owned(),
answered_at, answered_at: hive_sh4re::wire_time::WireTime(answered_at),
cancelled, cancelled,
target: target.map(str::to_owned), target: target.map(str::to_owned),
answer_refs, answer_refs,

View file

@ -27,6 +27,7 @@ use tokio_stream::{Stream, StreamExt};
use crate::container_view::{ContainerView, claude_has_session}; use crate::container_view::{ContainerView, claude_has_session};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle::{self, MANAGER_NAME}; use crate::lifecycle::{self, MANAGER_NAME};
use hive_sh4re::wire_time::WireTime;
mod approvals; mod approvals;
mod build_logs; mod build_logs;
@ -433,8 +434,7 @@ struct ApprovalHistoryView {
/// `approved` / `denied` / `failed`. /// `approved` / `denied` / `failed`.
status: &'static str, status: &'static str,
/// RFC 3339 UTC. 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: WireTime,
resolved_at: i64,
/// Operator-supplied deny reason (for `denied`) or build error /// Operator-supplied deny reason (for `denied`) or build error
/// (for `failed`). None on `approved`. /// (for `failed`). None on `approved`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -475,8 +475,7 @@ struct ApprovalView {
/// RFC 3339 UTC time the approval was queued. Rendered as a /// RFC 3339 UTC time the approval was queued. Rendered as a
/// relative time on the card so the operator can spot a stale /// relative time on the card so the operator can spot a stale
/// request. /// request.
#[serde(with = "hive_sh4re::wire_time::iso")] requested_at: WireTime,
requested_at: i64,
} }
/// Replace silent `.unwrap_or_default()` on the data sources behind /// Replace silent `.unwrap_or_default()` on the data sources behind
@ -908,7 +907,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
kind, kind,
sha_short, sha_short,
status, status,
resolved_at: a.resolved_at.unwrap_or(0), resolved_at: a.resolved_at.unwrap_or_default(),
note: a.note, note: a.note,
} }
} }
@ -1101,7 +1100,7 @@ async fn dashboard_history(State(state): State<AppState>) -> Response {
from, from,
to, to,
body, body,
at, at: hive_sh4re::wire_time::WireTime(at),
in_reply_to, in_reply_to,
file_refs, file_refs,
}) })
@ -1121,7 +1120,7 @@ async fn dashboard_history(State(state): State<AppState>) -> Response {
from, from,
to, to,
body, body,
at, at: hive_sh4re::wire_time::WireTime(at),
in_reply_to, in_reply_to,
file_refs, file_refs,
}) })
@ -1376,7 +1375,7 @@ async fn api_operator_inbox(State(state): State<AppState>) -> Response {
"id": id, "id": id,
"from": from, "from": from,
"body": body, "body": body,
"at": hive_sh4re::wire_time::to_iso(at), "at": hive_sh4re::wire_time::WireTime(at),
"in_reply_to": in_reply_to, "in_reply_to": in_reply_to,
"file_refs": file_refs, "file_refs": file_refs,
})) }))

View file

@ -9,6 +9,7 @@ use serde::Serialize;
use crate::container_view::ContainerView; use crate::container_view::ContainerView;
use crate::dashboard::{MetaInputView, TombstoneView}; use crate::dashboard::{MetaInputView, TombstoneView};
use crate::rebuild_queue::QueueEntry; use crate::rebuild_queue::QueueEntry;
use hive_sh4re::wire_time::WireTime;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
@ -38,8 +39,7 @@ pub enum DashboardEvent {
from: String, from: String,
to: String, to: String,
body: String, body: String,
#[serde(with = "hive_sh4re::wire_time::iso")] at: WireTime,
at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>, in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -54,8 +54,7 @@ pub enum DashboardEvent {
from: String, from: String,
to: String, to: String,
body: String, body: String,
#[serde(with = "hive_sh4re::wire_time::iso")] at: WireTime,
at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>, in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -97,8 +96,7 @@ pub enum DashboardEvent {
sha_short: Option<String>, sha_short: Option<String>,
/// `"approved"` / `"denied"` / `"failed"`. /// `"approved"` / `"denied"` / `"failed"`.
status: &'static str, status: &'static str,
#[serde(with = "hive_sh4re::wire_time::iso")] resolved_at: WireTime,
resolved_at: i64,
note: Option<String>, note: Option<String>,
description: Option<String>, description: Option<String>,
}, },
@ -114,10 +112,8 @@ pub enum DashboardEvent {
question: String, question: String,
options: Vec<String>, options: Vec<String>,
multi: bool, multi: bool,
#[serde(with = "hive_sh4re::wire_time::iso")] asked_at: WireTime,
asked_at: i64, deadline_at: Option<WireTime>,
#[serde(with = "hive_sh4re::wire_time::iso_opt")]
deadline_at: Option<i64>,
target: Option<String>, target: Option<String>,
/// Verified file-path tokens that appear in `question`. /// Verified file-path tokens that appear in `question`.
/// Same shape as broker `Sent`/`Delivered` events; the /// Same shape as broker `Sent`/`Delivered` events; the
@ -135,8 +131,7 @@ pub enum DashboardEvent {
id: i64, id: i64,
answer: String, answer: String,
answerer: String, answerer: String,
#[serde(with = "hive_sh4re::wire_time::iso")] answered_at: WireTime,
answered_at: i64,
cancelled: bool, cancelled: bool,
target: Option<String>, target: Option<String>,
/// Verified file-path tokens that appear in `answer`. /// Verified file-path tokens that appear in `answer`.
@ -337,7 +332,7 @@ mod tests {
from: "a".into(), from: "a".into(),
to: "b".into(), to: "b".into(),
body: String::new(), body: String::new(),
at: 0, at: hive_sh4re::wire_time::WireTime(0),
in_reply_to: None, in_reply_to: None,
file_refs: Vec::new(), file_refs: Vec::new(),
}, },
@ -347,7 +342,7 @@ mod tests {
from: "a".into(), from: "a".into(),
to: "b".into(), to: "b".into(),
body: String::new(), body: String::new(),
at: 0, at: hive_sh4re::wire_time::WireTime(0),
in_reply_to: None, in_reply_to: None,
file_refs: Vec::new(), file_refs: Vec::new(),
}, },
@ -368,7 +363,7 @@ mod tests {
approval_kind: "apply_commit", approval_kind: "apply_commit",
sha_short: None, sha_short: None,
status: "approved", status: "approved",
resolved_at: 0, resolved_at: hive_sh4re::wire_time::WireTime(0),
note: None, note: None,
description: None, description: None,
}, },
@ -379,7 +374,7 @@ mod tests {
question: String::new(), question: String::new(),
options: Vec::new(), options: Vec::new(),
multi: false, multi: false,
asked_at: 0, asked_at: hive_sh4re::wire_time::WireTime(0),
deadline_at: None, deadline_at: None,
target: None, target: None,
question_refs: Vec::new(), question_refs: Vec::new(),
@ -389,7 +384,7 @@ mod tests {
id: 1, id: 1,
answer: String::new(), answer: String::new(),
answerer: "a".into(), answerer: "a".into(),
answered_at: 0, answered_at: hive_sh4re::wire_time::WireTime(0),
cancelled: false, cancelled: false,
target: None, target: None,
answer_refs: Vec::new(), answer_refs: Vec::new(),
@ -452,7 +447,7 @@ mod tests {
seq: 1, seq: 1,
entry: crate::audit_log::AuditEntry { entry: crate::audit_log::AuditEntry {
id: 1, id: 1,
ts_unix: 0, ts_unix: hive_sh4re::wire_time::WireTime(0),
agent: "atlas".into(), agent: "atlas".into(),
action: "restart_infra".into(), action: "restart_infra".into(),
target: "hive-ci".into(), target: "hive-ci".into(),
@ -481,7 +476,7 @@ mod tests {
seq: 7, seq: 7,
entry: crate::audit_log::AuditEntry { entry: crate::audit_log::AuditEntry {
id: 42, id: 42,
ts_unix: 1_700_000_000, ts_unix: hive_sh4re::wire_time::WireTime(1_700_000_000),
agent: "atlas".into(), agent: "atlas".into(),
action: "restart_infra".into(), action: "restart_infra".into(),
target: "hive-gateway".into(), target: "hive-gateway".into(),

View file

@ -70,7 +70,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
agent: a.agent, agent: a.agent,
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
description: a.description, description: a.description,
age_seconds: saturating_age(now, a.requested_at), age_seconds: saturating_age(now, a.requested_at.secs()),
}); });
} }
for q in coord.questions.pending_all()? { for q in coord.questions.pending_all()? {
@ -83,7 +83,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
asker: q.asker, asker: q.asker,
target: q.target, target: q.target,
question: q.question, question: q.question,
age_seconds: saturating_age(now, q.asked_at), age_seconds: saturating_age(now, q.asked_at.secs()),
}); });
} }
for r in coord.broker.list_pending_reminders()? { for r in coord.broker.list_pending_reminders()? {
@ -95,7 +95,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
owner: r.agent, owner: r.agent,
message: r.message, message: r.message,
due_at: r.due_at, due_at: r.due_at,
age_seconds: saturating_age(now, r.created_at), age_seconds: saturating_age(now, r.created_at.secs()),
}); });
} }
Ok(out) Ok(out)
@ -114,7 +114,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
agent: a.agent, agent: a.agent,
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
description: a.description, description: a.description,
age_seconds: saturating_age(now, a.requested_at), age_seconds: saturating_age(now, a.requested_at.secs()),
}); });
} }
for q in coord.questions.pending_all()? { for q in coord.questions.pending_all()? {
@ -123,7 +123,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
asker: q.asker, asker: q.asker,
target: q.target, target: q.target,
question: q.question, question: q.question,
age_seconds: saturating_age(now, q.asked_at), age_seconds: saturating_age(now, q.asked_at.secs()),
}); });
} }
for r in coord.broker.list_pending_reminders()? { for r in coord.broker.list_pending_reminders()? {
@ -132,7 +132,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
owner: r.agent, owner: r.agent,
message: r.message, message: r.message,
due_at: r.due_at, due_at: r.due_at,
age_seconds: saturating_age(now, r.created_at), age_seconds: saturating_age(now, r.created_at.secs()),
}); });
} }
Ok(out) Ok(out)

View file

@ -497,7 +497,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
from, from,
to, to,
body, body,
at, at: hive_sh4re::wire_time::WireTime(at),
in_reply_to, in_reply_to,
file_refs, file_refs,
}); });
@ -517,7 +517,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
from, from,
to, to,
body, body,
at, at: hive_sh4re::wire_time::WireTime(at),
in_reply_to, in_reply_to,
file_refs, file_refs,
}); });

View file

@ -14,6 +14,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use hive_sh4re::wire_time::WireTime;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
@ -74,15 +75,12 @@ pub struct OpQuestion {
pub question: String, pub question: String,
pub options: Vec<String>, pub options: Vec<String>,
pub multi: bool, pub multi: bool,
#[serde(with = "hive_sh4re::wire_time::iso")] pub asked_at: WireTime,
pub asked_at: i64,
/// Deadline after which a watchdog auto-resolves the question with /// Deadline after which a watchdog auto-resolves the question with
/// answer `[expired]`. `None` = no expiry. Surfaced on the /// answer `[expired]`. `None` = no expiry. Surfaced on the
/// dashboard as a remaining-time chip. /// dashboard as a remaining-time chip.
#[serde(with = "hive_sh4re::wire_time::iso_opt")] pub deadline_at: Option<WireTime>,
pub deadline_at: Option<i64>, pub answered_at: Option<WireTime>,
#[serde(with = "hive_sh4re::wire_time::iso_opt")]
pub answered_at: Option<i64>,
pub answer: Option<String>, pub answer: Option<String>,
/// Recipient of the question. `None` = the operator (dashboard /// Recipient of the question. `None` = the operator (dashboard
/// path); `Some(<agent>)` = a peer agent asked via /// path); `Some(<agent>)` = a peer agent asked via
@ -290,10 +288,14 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
question: row.get(2)?, question: row.get(2)?,
options, options,
multi: multi != 0, multi: multi != 0,
asked_at: row.get(5)?, asked_at: hive_sh4re::wire_time::WireTime(row.get(5)?),
answered_at: row.get(6)?, answered_at: row
.get::<_, Option<i64>>(6)?
.map(hive_sh4re::wire_time::WireTime),
answer: row.get(7)?, answer: row.get(7)?,
deadline_at: row.get(8)?, deadline_at: row
.get::<_, Option<i64>>(8)?
.map(hive_sh4re::wire_time::WireTime),
target: row.get(9)?, target: row.get(9)?,
}) })
} }

View file

@ -2018,8 +2018,8 @@ fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSc
owner: s.owner, owner: s.owner,
body: s.body, body: s.body,
interval_seconds: s.interval_seconds, interval_seconds: s.interval_seconds,
next_fire_at_unix: s.next_fire_at_unix, next_fire_at_unix: hive_sh4re::wire_time::WireTime(s.next_fire_at_unix),
created_at_unix: s.created_at_unix, created_at_unix: hive_sh4re::wire_time::WireTime(s.created_at_unix),
source: match s.source { source: match s.source {
crate::scheduled_prompts::ScheduleSource::Operator => { crate::scheduled_prompts::ScheduleSource::Operator => {
hive_sh4re::WireScheduleSource::Operator hive_sh4re::WireScheduleSource::Operator
@ -2028,16 +2028,16 @@ fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSc
hive_sh4re::WireScheduleSource::Approval { id } hive_sh4re::WireScheduleSource::Approval { id }
} }
}, },
cancelled_at_unix: s.cancelled_at_unix, cancelled_at_unix: s.cancelled_at_unix.map(hive_sh4re::wire_time::WireTime),
paused_at_unix: s.paused_at_unix, paused_at_unix: s.paused_at_unix.map(hive_sh4re::wire_time::WireTime),
description: s.description, description: s.description,
targets: s targets: s
.targets .targets
.into_iter() .into_iter()
.map(|t| hive_sh4re::WireScheduleTarget { .map(|t| hive_sh4re::WireScheduleTarget {
target: t.target, target: t.target,
cancelled_at_unix: t.cancelled_at_unix, cancelled_at_unix: t.cancelled_at_unix.map(hive_sh4re::wire_time::WireTime),
last_fired_at_unix: t.last_fired_at_unix, last_fired_at_unix: t.last_fired_at_unix.map(hive_sh4re::wire_time::WireTime),
last_result: t.last_result, last_result: t.last_result,
}) })
.collect(), .collect(),
@ -2131,8 +2131,8 @@ mod tests {
owner: "operator".to_owned(), owner: "operator".to_owned(),
body: "ping".to_owned(), body: "ping".to_owned(),
interval_seconds: None, interval_seconds: None,
next_fire_at_unix: 0, next_fire_at_unix: hive_sh4re::wire_time::WireTime(0),
created_at_unix: 0, created_at_unix: hive_sh4re::wire_time::WireTime(0),
source: hive_sh4re::WireScheduleSource::Operator, source: hive_sh4re::WireScheduleSource::Operator,
cancelled_at_unix: None, cancelled_at_unix: None,
paused_at_unix: None, paused_at_unix: None,

View file

@ -1,5 +1,6 @@
//! Wire types shared between `hive-c0re` and the in-container harness. //! Wire types shared between `hive-c0re` and the in-container harness.
use crate::wire_time::WireTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod assets; pub mod assets;
@ -209,15 +210,10 @@ pub struct Approval {
/// hive-c0re refreshes this + re-renders the card for re-review. /// hive-c0re refreshes this + re-renders the card for re-review.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_sha: Option<String>, pub fetched_sha: Option<String>,
#[serde(with = "crate::wire_time::iso")] pub requested_at: WireTime,
pub requested_at: i64,
pub status: ApprovalStatus, pub status: ApprovalStatus,
#[serde( #[serde(default, skip_serializing_if = "Option::is_none")]
default, pub resolved_at: Option<WireTime>,
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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>, pub note: Option<String>,
/// Free-text description the manager attached at submission time; /// Free-text description the manager attached at submission time;
@ -453,8 +449,7 @@ pub enum LooseEnd {
id: i64, id: i64,
owner: String, owner: String,
message: String, message: String,
#[serde(with = "crate::wire_time::iso")] due_at: WireTime,
due_at: i64,
age_seconds: u64, age_seconds: u64,
}, },
/// Undelivered inbox messages waiting to be `recv`'d by this agent. /// Undelivered inbox messages waiting to be `recv`'d by this agent.
@ -1419,27 +1414,17 @@ pub struct WireSchedule {
pub body: String, pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>, pub interval_seconds: Option<u64>,
#[serde(with = "crate::wire_time::iso")] pub next_fire_at_unix: WireTime,
pub next_fire_at_unix: i64, pub created_at_unix: WireTime,
#[serde(with = "crate::wire_time::iso")]
pub created_at_unix: i64,
pub source: WireScheduleSource, pub source: WireScheduleSource,
#[serde( #[serde(default, skip_serializing_if = "Option::is_none")]
default, pub cancelled_at_unix: Option<WireTime>,
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; /// Set while the schedule is paused. Worker skips paused rows;
/// they keep their `next_fire_at_unix` so resuming at any time /// they keep their `next_fire_at_unix` so resuming at any time
/// fires at the next intended instant (no catch-up clamp needed /// fires at the next intended instant (no catch-up clamp needed
/// — a paused schedule simply slips its next fire). /// — a paused schedule simply slips its next fire).
#[serde( #[serde(default, skip_serializing_if = "Option::is_none")]
default, pub paused_at_unix: Option<WireTime>,
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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
pub targets: Vec<WireScheduleTarget>, pub targets: Vec<WireScheduleTarget>,
@ -1455,18 +1440,10 @@ pub enum WireScheduleSource {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireScheduleTarget { pub struct WireScheduleTarget {
pub target: String, pub target: String,
#[serde( #[serde(default, skip_serializing_if = "Option::is_none")]
default, pub cancelled_at_unix: Option<WireTime>,
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<WireTime>,
)]
pub cancelled_at_unix: Option<i64>,
#[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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>, pub last_result: Option<String>,
} }

View file

@ -11,13 +11,63 @@
//! safe (an old peer emitting epoch ints into a new reader) and lets //! safe (an old peer emitting epoch ints into a new reader) and lets
//! previously persisted JSON blobs re-load unchanged. //! previously persisted JSON blobs re-load unchanged.
//! //!
//! Usage: `#[serde(with = "crate::wire_time::iso")]` on `i64` fields, //! Usage: type timestamp fields as [`WireTime`] / `Option<WireTime>`
//! `#[serde(with = "crate::wire_time::iso_opt")]` on `Option<i64>` //! (keep the usual `default` + `skip_serializing_if` attributes on the
//! (keep the usual `default` + `skip_serializing_if` attributes). //! optional form). The type IS the adaptor — no `#[serde(with = …)]`
//! needed.
use chrono::{DateTime, SecondsFormat, Utc}; use chrono::{DateTime, SecondsFormat, Utc};
use serde::Deserialize; use serde::Deserialize;
/// A timestamp on the wire: unix-epoch seconds in Rust, RFC 3339 UTC
/// string in JSON. Carries "this is a timestamp" in the type system
/// instead of a bare `i64` — the module docs above describe the wire
/// behaviour (ISO out, lenient epoch-or-ISO in).
///
/// The inner value is public: arithmetic like `now + delay` stays
/// plain integer math (`WireTime(now_secs + delay)`), no chrono types
/// leak into call sites.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WireTime(pub i64);
impl WireTime {
/// The wrapped unix-epoch seconds.
#[must_use]
pub fn secs(self) -> i64 {
self.0
}
/// RFC 3339 UTC `Z` string form (same as the serialized shape).
#[must_use]
pub fn to_iso(self) -> String {
to_iso(self.0)
}
}
impl From<i64> for WireTime {
fn from(secs: i64) -> Self {
Self(secs)
}
}
impl std::fmt::Display for WireTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&to_iso(self.0))
}
}
impl serde::Serialize for WireTime {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&to_iso(self.0))
}
}
impl<'de> Deserialize<'de> for WireTime {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
EpochOrIso::deserialize(de)?.into_secs().map(Self)
}
}
/// Format unix-epoch seconds as an RFC 3339 UTC string with a `Z` /// 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 /// suffix. Out-of-range values (never produced by our clocks) clamp to
/// the epoch rather than erroring — serialization must not fail. /// the epoch rather than erroring — serialization must not fail.
@ -52,61 +102,23 @@ impl EpochOrIso {
} }
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::WireTime;
#[derive(Serialize, Deserialize, PartialEq, Debug)] #[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Row { struct Row {
#[serde(with = "crate::wire_time::iso")] at: WireTime,
at: i64, #[serde(default, skip_serializing_if = "Option::is_none")]
#[serde( maybe_at: Option<WireTime>,
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
maybe_at: Option<i64>,
} }
#[test] #[test]
fn serializes_epoch_as_rfc3339_z() { fn serializes_epoch_as_rfc3339_z() {
let json = serde_json::to_string(&Row { let json = serde_json::to_string(&Row {
at: 1_751_480_000, at: WireTime(1_751_480_000),
maybe_at: None, maybe_at: None,
}) })
.unwrap(); .unwrap();
@ -116,8 +128,8 @@ mod tests {
#[test] #[test]
fn round_trips_and_serializes_some() { fn round_trips_and_serializes_some() {
let row = Row { let row = Row {
at: 0, at: WireTime(0),
maybe_at: Some(1_751_480_000), maybe_at: Some(WireTime(1_751_480_000)),
}; };
let json = serde_json::to_string(&row).unwrap(); let json = serde_json::to_string(&row).unwrap();
assert_eq!( assert_eq!(
@ -132,13 +144,13 @@ mod tests {
// Rolling-deploy skew: an old writer still emits bare epoch // Rolling-deploy skew: an old writer still emits bare epoch
// integers — the lenient reader must accept them. // integers — the lenient reader must accept them.
let row: Row = serde_json::from_str(r#"{"at":1751480000,"maybe_at":1751480000}"#).unwrap(); 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.at, WireTime(1_751_480_000));
assert_eq!(row.maybe_at, Some(1_751_480_000)); assert_eq!(row.maybe_at, Some(WireTime(1_751_480_000)));
} }
#[test] #[test]
fn deserializes_offset_form_normalized_to_utc() { fn deserializes_offset_form_normalized_to_utc() {
let row: Row = serde_json::from_str(r#"{"at":"2025-07-02T20:13:20+02:00"}"#).unwrap(); 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); assert_eq!(row.at, WireTime(1_751_480_000));
} }
} }