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/docs/conventions.md b/docs/conventions.md index 050358c6..9bd03501 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -237,8 +237,9 @@ Per-variant fields: `target = None` = operator-routed (dashboard); `Some(agent)` = peer-to-peer thread. - `Reminder { id, owner, message, due_at, age_seconds }` — - `due_at` is the absolute unix timestamp the scheduler is - targeting; clients compute time-until-fire as `due_at - now`. + `due_at` is the absolute time the scheduler is targeting (RFC + 3339 on the wire, see *Timestamps on the wire* below); clients + compute time-until-fire against it. - `PendingMessages { count }` — undelivered inbox messages the agent still owes itself a `recv` for. Informational + not 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`). 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 The MCP tool surface an agent receives is derived from a set of named diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 61fe767a..041235fa 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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 `err` red, with an `err` whose `detail` starts `denied:` (a capability 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 `audit_log` store records every privileged-action attempt (ok / err / 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. - `GET /api/audit-log` — agent-initiated privileged-action audit trail. Returns `{ entries, total }`: `entries` is a `Vec` - (`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 500; `total` is the full row count for a "latest 500 of N" header. Backs the LOGS page AUDIT sub-tab. 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