Compare commits

...
17 changed files with 269 additions and 49 deletions

2
Cargo.lock generated
View file

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

View file

@ -28,6 +28,7 @@ libc = "0.2"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
base64 = "0.22" base64 = "0.22"
bcrypt = "0.19" bcrypt = "0.19"
chrono = { version = "0.4", default-features = false, features = ["std"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
hive-sh4re = { path = "hive-sh4re" } hive-sh4re = { path = "hive-sh4re" }

View file

@ -237,8 +237,9 @@ Per-variant fields:
`target = None` = operator-routed (dashboard); `Some(agent)` = `target = None` = operator-routed (dashboard); `Some(agent)` =
peer-to-peer thread. peer-to-peer thread.
- `Reminder { id, owner, message, due_at, age_seconds }` - `Reminder { id, owner, message, due_at, age_seconds }`
`due_at` is the absolute unix timestamp the scheduler is `due_at` is the absolute time the scheduler is targeting (RFC
targeting; clients compute time-until-fire as `due_at - now`. 3339 on the wire, see *Timestamps on the wire* below); clients
compute time-until-fire against it.
- `PendingMessages { count }` — undelivered inbox messages the - `PendingMessages { count }` — undelivered inbox messages the
agent still owes itself a `recv` for. Informational + not agent still owes itself a `recv` for. Informational + not
cancellable (drain with `recv`); only emitted when `count > 0`, cancellable (drain with `recv`); only emitted when `count > 0`,
@ -296,6 +297,21 @@ status_text, status_set_at, hive_name, swarm_name }`:
`services.hyperhive.hiveName` / `services.hyperhive.swarmName`). `services.hyperhive.hiveName` / `services.hyperhive.swarmName`).
Both `None` when the options aren't configured. Both `None` when the options aren't configured.
### Timestamps on the wire
Timestamp fields that cross a JSON boundary (dashboard API + SSE,
the wire structs in hive-sh4re) serialize as **RFC 3339 UTC strings**
(`2026-07-02T18:30:00Z`) via `hive_sh4re::wire_time` — Rust keeps the
fields as `i64` unix seconds internally, only the JSON representation
changes, and deserialization leniently accepts both the string form
and the legacy bare integer (rolling-deploy skew, persisted blobs).
**Input-direction** fields agents compute as epoch (`first_fire_at_unix`,
schedule-edit `next_fire_at_unix`, `Wakeup::At`) stay integers. The
`*_unix` field *names* are kept for now — renaming is the wire-types
refactor's concern. The dashboard frontend parses via
`util.js::epochSec` wherever it needs arithmetic and feeds the string
straight to `new Date(s)` for display.
## Tool groups ## Tool groups
The MCP tool surface an agent receives is derived from a set of named The MCP tool surface an agent receives is derived from a set of named

View file

@ -676,7 +676,7 @@ target / outcome / detail); the filter box is a client-side substring
match over the cached rows. The outcome badge colours `ok` green and match over the cached rows. The outcome badge colours `ok` green and
`err` red, with an `err` whose `detail` starts `denied:` (a capability `err` red, with an `err` whose `detail` starts `denied:` (a capability
refusal) shown amber and labelled `denied` so it reads apart from an refusal) shown amber and labelled `denied` so it reads apart from an
execution failure. `ts_unix` is unix seconds; a 30 s ticker keeps the execution failure. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
relative "ago" column honest while the tab is in view. The backing relative "ago" column honest while the tab is in view. The backing
`audit_log` store records every privileged-action attempt (ok / err / `audit_log` store records every privileged-action attempt (ok / err /
denied). New entries live-append without a refresh: an `audit_entry_added` denied). New entries live-append without a refresh: an `audit_entry_added`
@ -1076,7 +1076,7 @@ that's a browser-level decision, not ours.
a managed container; rendered in the side panel. a managed container; rendered in the side panel.
- `GET /api/audit-log` — agent-initiated privileged-action audit - `GET /api/audit-log` — agent-initiated privileged-action audit
trail. Returns `{ entries, total }`: `entries` is a `Vec<AuditEntry>` trail. Returns `{ entries, total }`: `entries` is a `Vec<AuditEntry>`
(`id`, `ts_unix` in seconds, `agent`, `action`, `target`, `outcome` (`id`, `ts_unix` as RFC 3339, `agent`, `action`, `target`, `outcome`
`"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to `"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to
500; `total` is the full row count for a "latest 500 of N" header. 500; `total` is the full row count for a "latest 500 of N" header.
Backs the LOGS page AUDIT sub-tab. Backs the LOGS page AUDIT sub-tab.

View file

@ -16,7 +16,7 @@
import { $, el, form, Panel, appendLinkified } from './common.js'; import { $, el, form, Panel, appendLinkified } from './common.js';
import { themedToast } from './modal.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'; import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
// Registered by the dashboard entry at boot; defaults to a no-op so the // 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 all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead); mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark)); 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' }); const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) { for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' }); 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 // 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). // obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) { 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', { head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), title: 'requested ' + new Date(a.requested_at).toLocaleString(),
'data-requested-at': String(a.requested_at), 'data-requested-at': String(requestedSec),
}, 'requested ' + fmtAgo(a.requested_at))); }, 'requested ' + fmtAgo(a.requested_at)));
} }
li.append(head); li.append(head);
@ -556,7 +557,7 @@ function questionRowFingerprint(q) {
// Event listeners attached here (keydown on textarea, submit on form) are // Event listeners attached here (keydown on textarea, submit on form) are
// preserved in the reused node — no re-attachment needed. // preserved in the reused node — no re-attachment needed.
function buildQuestionLi(q) { 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 targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' }, 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 // Tag the chip with its deadline so the global 1s ticker
// can refresh the text without re-rendering the questions section. // can refresh the text without re-rendering the questions section.
const ttlEl = el('span', { 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( ttlEl.textContent = formatTtl(
q.deadline_at - Math.floor(Date.now() / 1000), epochSec(q.deadline_at) - Math.floor(Date.now() / 1000),
); );
head.append(' ', ttlEl); head.append(' ', ttlEl);
} }
@ -694,7 +695,7 @@ export function renderQuestions() {
// <li> nodes (preserving textarea/checkbox state) and only rebuilds // <li> nodes (preserving textarea/checkbox state) and only rebuilds
// cache-miss rows, so we no longer wipe the DOM at the start. // cache-miss rows, so we no longer wipe the DOM at the start.
const openDetails = snapshotOpenDetails(root); 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; const allPending = questionsState.pending;
// Filter chips. Always include `all` / `operator` / `peer`; add // Filter chips. Always include `all` / `operator` / `peer`; add

View file

@ -18,6 +18,7 @@ import {
appendLinkified, appendLinkified,
openStream, initServerWarnings, openStream, initServerWarnings,
} from './common.js'; } from './common.js';
import { epochSec } from './util.js';
(() => { (() => {
NOTIF.bind(); NOTIF.bind();
@ -92,7 +93,7 @@ import {
const flow = $('msgflow'); const flow = $('msgflow');
if (!flow) return; if (!flow) return;
flow.replaceChildren(); 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 // Pulse the page banner whenever a broker event lands. The
// `.banner` element lives in the dashboard's <footer> rather than // `.banner` element lives in the dashboard's <footer> rather than
// in the flow chrome — `pulseBanner` no-ops on /flow.html since // in the flow chrome — `pulseBanner` no-ops on /flow.html since
@ -179,7 +180,7 @@ import {
const recentSent = new Map(); // broker row id → { row, at } const recentSent = new Map(); // broker row id → { row, at }
function rememberSent(ev, row) { function rememberSent(ev, row) {
if (ev.id == null || ev.id <= 0) return; if (ev.id == null || ev.id <= 0) return;
recentSent.set(ev.id, { row, at: ev.at }); recentSent.set(ev.id, { row, at: epochSec(ev.at) });
// Bound the map — drop the oldest entries once it grows past a // Bound the map — drop the oldest entries once it grows past a
// page of un-collapsed sends (insertion order = oldest first). // page of un-collapsed sends (insertion order = oldest first).
while (recentSent.size > 256) { while (recentSent.size > 256) {
@ -190,7 +191,7 @@ import {
function collapseDelivered(ev) { function collapseDelivered(ev) {
if (ev.id == null || ev.id <= 0) return false; if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id); const s = recentSent.get(ev.id);
if (!s || ev.at - s.at > COLLAPSE_SECS) return false; if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow'); const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓'; if (arrow) arrow.textContent = '✓';
// Re-style the row as delivered (green ✓) — the collapsed line now // Re-style the row as delivered (green ✓) — the collapsed line now

View file

@ -15,6 +15,7 @@
import { import {
$, el, fmtAgeSecs, openStream, initServerWarnings, $, el, fmtAgeSecs, openStream, initServerWarnings,
} from './common.js'; } from './common.js';
import { epochSec } from './util.js';
import { createTabStrip } from '@hive/shared/tabs.js'; import { createTabStrip } from '@hive/shared/tabs.js';
(() => { (() => {
@ -170,10 +171,11 @@ import { createTabStrip } from '@hive/shared/tabs.js';
let auditTotal = 0; let auditTotal = 0;
let auditFetching = false; let auditFetching = false;
// ts_unix is unix seconds — fmtAgeSecs wants an age in seconds. // ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age
function auditFmtWhen(tsUnix) { // in seconds, so normalize via epochSec first.
if (!tsUnix) return ''; function auditFmtWhen(ts) {
const age = Math.floor(Date.now() / 1000) - tsUnix; if (!ts) return '';
const age = Math.floor(Date.now() / 1000) - epochSec(ts);
return fmtAgeSecs(Math.max(0, age)) + ' ago'; return fmtAgeSecs(Math.max(0, age)) + ' ago';
} }
@ -234,7 +236,7 @@ import { createTabStrip } from '@hive/shared/tabs.js';
tbody.append(el('tr', {}, tbody.append(el('tr', {},
el('td', { el('td', {
class: 'audit-when meta', class: 'audit-when meta',
title: e.ts_unix ? new Date(e.ts_unix * 1000).toISOString() : '', title: e.ts_unix ? new Date(e.ts_unix).toISOString() : '',
}, auditFmtWhen(e.ts_unix)), }, auditFmtWhen(e.ts_unix)),
el('td', { class: 'audit-agent' }, e.agent || ''), el('td', { class: 'audit-agent' }, e.agent || ''),
el('td', { class: 'audit-action' }, e.action || ''), el('td', { class: 'audit-action' }, e.action || ''),

View file

@ -13,7 +13,7 @@
import { $, el, appendLinkified } from './common.js'; import { $, el, appendLinkified } from './common.js';
import { themedConfirm, themedToast } from './modal.js'; import { themedConfirm, themedToast } from './modal.js';
import { paintAtomic, fmtAgo, fmtDuration } from './util.js'; import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
import { containersState } from './state.js'; import { containersState } from './state.js';
export async function refreshReminders() { export async function refreshReminders() {
@ -47,7 +47,7 @@ function renderReminders(rows) {
for (const r of rows) { for (const r of rows) {
const failed = (r.attempt_count || 0) > 0; const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = r.due_at - Math.floor(Date.now() / 1000); const dueIn = epochSec(r.due_at) - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0 const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}` ? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`; : `in ${fmtDuration(dueIn)}`;
@ -55,8 +55,8 @@ function renderReminders(rows) {
el('span', { class: 'agent' }, r.agent), ' ', el('span', { class: 'agent' }, r.agent), ' ',
el('span', { el('span', {
class: 'meta reminder-due', class: 'meta reminder-due',
title: new Date(r.due_at * 1000).toISOString(), title: new Date(r.due_at).toISOString(),
'data-due-at': String(r.due_at), 'data-due-at': String(epochSec(r.due_at)),
}, dueLabel), }, dueLabel),
' ', ' ',
el('span', { class: 'meta' }, `· id ${r.id}`), el('span', { class: 'meta' }, `· id ${r.id}`),
@ -394,7 +394,7 @@ function renderSchedulesList() {
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0; const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0; const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
if (aOrd !== bOrd) return aOrd - bOrd; if (aOrd !== bOrd) return aOrd - bOrd;
return a.next_fire_at_unix - b.next_fire_at_unix; return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
}); });
for (const s of sorted) { for (const s of sorted) {
tbody.append(renderScheduleRow(s, agents)); tbody.append(renderScheduleRow(s, agents));
@ -657,24 +657,24 @@ function renderScheduleRow(s, agents) {
// absolute ISO in the title. // absolute ISO in the title.
const nextCell = el('td', { class: 'meta schedules-table-next-col' }); const nextCell = el('td', { class: 'meta schedules-table-next-col' });
if (cancelled) { if (cancelled) {
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString(); nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix).toISOString();
nextCell.textContent = 'cancelled'; nextCell.textContent = 'cancelled';
} else if (paused) { } else if (paused) {
nextCell.title = 'paused since ' nextCell.title = 'paused since '
+ new Date(s.paused_at_unix * 1000).toISOString() + new Date(s.paused_at_unix).toISOString()
+ '\nwould fire at ' + '\nwould fire at '
+ new Date(s.next_fire_at_unix * 1000).toISOString(); + new Date(s.next_fire_at_unix).toISOString();
nextCell.append( nextCell.append(
el('span', { class: 'sched-paused-label' }, '⏸ paused'), el('span', { class: 'sched-paused-label' }, '⏸ paused'),
); );
} else { } else {
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000); const dueIn = epochSec(s.next_fire_at_unix) - Math.floor(Date.now() / 1000);
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString(); nextCell.title = new Date(s.next_fire_at_unix).toISOString();
nextCell.textContent = dueIn <= 0 nextCell.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(s.next_fire_at_unix) ? 'overdue ' + fmtAgo(s.next_fire_at_unix)
: fmtDuration(dueIn); : fmtDuration(dueIn);
nextCell.classList.add('sched-due'); nextCell.classList.add('sched-due');
nextCell.dataset.dueAt = String(s.next_fire_at_unix); nextCell.dataset.dueAt = String(epochSec(s.next_fire_at_unix));
} }
tr.append(nextCell); tr.append(nextCell);
@ -841,7 +841,7 @@ function renderScheduleEditForm(s) {
}); });
firstFireInput.value = carry.next_fire !== undefined firstFireInput.value = carry.next_fire !== undefined
? carry.next_fire ? carry.next_fire
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000)); : isoForDatetimeLocal(new Date(s.next_fire_at_unix));
form_.append(scheduleField('next fire', firstFireInput)); form_.append(scheduleField('next fire', firstFireInput));
const intervalCx = buildIntervalComposer({ const intervalCx = buildIntervalComposer({
@ -968,7 +968,7 @@ async function submitEditSchedule(originalSchedule, form_) {
if (newDescription !== (s.description || '')) { if (newDescription !== (s.description || '')) {
patch.description = newDescription || null; patch.description = newDescription || null;
} }
if (newNextFireUnix !== s.next_fire_at_unix) { if (newNextFireUnix !== epochSec(s.next_fire_at_unix)) {
patch.next_fire_at_unix = newNextFireUnix; patch.next_fire_at_unix = newNextFireUnix;
} }
if (newIntervalSeconds !== (s.interval_seconds || null)) { if (newIntervalSeconds !== (s.interval_seconds || null)) {

View file

@ -19,9 +19,18 @@ export function paintAtomic(liveRoot, build) {
liveRoot.replaceChildren(buf); liveRoot.replaceChildren(buf);
} }
// Relative age of a unix timestamp, coarsened to one unit ("5m ago"). // Epoch seconds from an API timestamp. The hive-c0re dashboard API
export function fmtAgo(unixSecs) { // ships timestamps as RFC 3339 strings; a few feeds (rebuild queue,
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs)); // meta inputs, turn stats) still carry unix-second numbers, so
// numbers pass through unchanged.
export function epochSec(ts) {
return typeof ts === 'number' ? ts : Math.floor(Date.parse(ts) / 1000);
}
// Relative age of a timestamp (RFC 3339 string or unix seconds),
// coarsened to one unit ("5m ago").
export function fmtAgo(ts) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - epochSec(ts)));
if (ageSec < 60) return ageSec + 's ago'; if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago'; if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago'; if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';

View file

@ -89,6 +89,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: i64, 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,

View file

@ -74,7 +74,9 @@ 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: i64, pub due_at: i64,
#[serde(with = "hive_sh4re::wire_time::iso")]
pub created_at: i64, 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

View file

@ -432,7 +432,8 @@ struct ApprovalHistoryView {
sha_short: Option<String>, sha_short: Option<String>,
/// `approved` / `denied` / `failed`. /// `approved` / `denied` / `failed`.
status: &'static str, 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, 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`.
@ -471,8 +472,10 @@ struct ApprovalView {
/// `None` for every other kind. /// `None` for every other kind.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
commit_ref: Option<String>, commit_ref: Option<String>,
/// Unix seconds the approval was queued. Rendered as a relative /// RFC 3339 UTC time the approval was queued. Rendered as a
/// time on the card so the operator can spot a stale request. /// relative time on the card so the operator can spot a stale
/// request.
#[serde(with = "hive_sh4re::wire_time::iso")]
requested_at: i64, requested_at: i64,
} }
@ -1373,7 +1376,7 @@ async fn api_operator_inbox(State(state): State<AppState>) -> Response {
"id": id, "id": id,
"from": from, "from": from,
"body": body, "body": body,
"at": at, "at": hive_sh4re::wire_time::to_iso(at),
"in_reply_to": in_reply_to, "in_reply_to": in_reply_to,
"file_refs": file_refs, "file_refs": file_refs,
})) }))

View file

@ -38,6 +38,7 @@ pub enum DashboardEvent {
from: String, from: String,
to: String, to: String,
body: String, body: String,
#[serde(with = "hive_sh4re::wire_time::iso")]
at: i64, 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>,
@ -53,6 +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: i64, 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>,
@ -95,6 +97,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: i64, resolved_at: i64,
note: Option<String>, note: Option<String>,
description: Option<String>, description: Option<String>,
@ -111,7 +114,9 @@ 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: i64, asked_at: i64,
#[serde(with = "hive_sh4re::wire_time::iso_opt")]
deadline_at: Option<i64>, 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`.
@ -130,6 +135,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: i64, answered_at: i64,
cancelled: bool, cancelled: bool,
target: Option<String>, target: Option<String>,

View file

@ -74,11 +74,14 @@ 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: i64, pub asked_at: i64,
/// Absolute unix-seconds deadline after which a watchdog auto- /// Deadline after which a watchdog auto-resolves the question with
/// resolves the question with answer `[expired]`. `None` = no /// answer `[expired]`. `None` = no expiry. Surfaced on the
/// 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<i64>, pub deadline_at: Option<i64>,
#[serde(with = "hive_sh4re::wire_time::iso_opt")]
pub answered_at: Option<i64>, 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

View file

@ -7,5 +7,9 @@ version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
chrono.workspace = true
schemars.workspace = true schemars.workspace = true
serde.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 assets;
pub mod paths; pub mod paths;
pub mod priv_proto; pub mod priv_proto;
pub mod wire_time;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Host admin socket — /run/hyperhive/host.sock // 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. /// 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: i64, pub requested_at: i64,
pub status: ApprovalStatus, 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>, 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>,
@ -437,6 +443,7 @@ pub enum LooseEnd {
id: i64, id: i64,
owner: String, owner: String,
message: String, message: String,
#[serde(with = "crate::wire_time::iso")]
due_at: i64, due_at: i64,
age_seconds: u64, age_seconds: u64,
}, },
@ -1402,16 +1409,26 @@ 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: i64, pub next_fire_at_unix: i64,
#[serde(with = "crate::wire_time::iso")]
pub created_at_unix: i64, pub created_at_unix: i64,
pub source: WireScheduleSource, 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>, 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(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>, 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>,
@ -1428,9 +1445,17 @@ 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(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>, 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>, 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>,

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;
/// 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);
}
}