term_msg: carry the source event's time on every row

A terminal row published on `$SWARM.term.<hive>.<agent>` goes out bare,
with no envelope around it and no server-side stamp, so a subscriber had
nothing to place the row in time with beyond its own receipt clock —
wrong by the queue's latency and meaningless for anything read later
than live.

`TermMsg` gains `ts`, ISO 8601 UTC. `classify` takes the event's own
unix-seconds stamp and applies it to every row that event expands into,
so a row replayed out of sqlite says when it happened rather than when
it was read, and a row that sat in a lagging subscriber's buffer does
not lie about its time. The oversize degrade keeps it; only the body is
ever spent.

`TermEnvelope` stops duplicating `ts` and keeps `seq`: the dedup counter
is a real transport concern, the event's time is not, now that it rides
on the row. Nothing in the frontend read `envelope.ts` — only the type
declared it.

Refs #4321
This commit is contained in:
atlas 2026-09-13 14:01:14 +02:00 • committed by mara
commit 053340128b
5 changed files with 206 additions and 38 deletions

View file

@ -81,8 +81,9 @@ fn hive_from_client_id(client_id: &str) -> Option<&str> {
///
/// The body is the only field that carries arbitrary length — a diff, a whole
/// tool result — so it is the only one worth spending: replacing it keeps the
/// row's identity, level and summary, which is what makes the row readable at
/// all, and a reader sees that something was there rather than seeing nothing.
/// row's identity, level, summary and time, which is what makes the row
/// readable at all, and a reader sees that something was there rather than
/// seeing nothing.
///
/// `None` means even the degraded row does not fit, so the caller reports it
/// instead of publishing. That matters more than it looks: an oversize publish
@ -162,7 +163,7 @@ async fn run(mut rx: broadcast::Receiver<BusEvent>, subject: String) {
// Every sender is gone, so the harness is shutting down.
Err(broadcast::error::RecvError::Closed) => return,
};
for msg in classify(&event.event, &mut ctx) {
for msg in classify(&event.event, event.ts, &mut ctx) {
publish(&client, &subject, msg).await;
}
}
@ -203,7 +204,12 @@ async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) {
#[cfg(test)]
mod tests {
use super::{DROPPED_BODY, fit, hive_from_client_id};
use crate::term_msg::{BodyFormat, Level, TermMsg};
use crate::events::LiveEvent;
use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg, classify};
/// A row's time as `classify` renders it, for the tests below that need
/// one without classifying an event to get it.
const ROW_TS: &str = "2026-09-13T12:35:03Z";
#[test]
fn a_client_id_names_the_hive_between_the_prefix_and_the_suffix() {
@ -256,7 +262,8 @@ mod tests {
let msg = TermMsg::new(Level::Info, "Edit(src/main.rs)")
.icon("🔧")
.body("x".repeat(limit * 4), Some(BodyFormat::Diff))
.coalesce("tool-1");
.coalesce("tool-1")
.at(ROW_TS);
let fitted = fit(msg, limit).expect("dropping the body brings this under the limit");
assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY));
// The tag describes a body that is no longer there; left set, a reader
@ -266,6 +273,9 @@ mod tests {
assert_eq!(fitted.summary, "Edit(src/main.rs)");
assert_eq!(fitted.icon.as_deref(), Some("🔧"));
assert_eq!(fitted.coalesce_key.as_deref(), Some("tool-1"));
// Including the time: a degraded row that lost it would be a row a
// subscriber cannot place, and nothing downstream could tell.
assert_eq!(fitted.ts, ROW_TS);
assert!(
serde_json::to_vec(&fitted).expect("serialises").len() <= limit,
"the degraded row must be under the limit, not merely smaller"
@ -308,4 +318,23 @@ mod tests {
let fitted = fit(msg, limit).expect("dropping the body fits");
assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY));
}
/// What a subscriber actually receives: the payload this module hands
/// `publish` is the bare row, so the time has to be *in* it. A `ts` that
/// existed in Rust but never serialized would leave the queue exactly as
/// timeless as it was before.
#[test]
fn the_published_payload_carries_the_events_own_time() {
let ev = LiveEvent::TurnEnd {
ok: true,
note: None,
};
let mut ctx = ClassifyCtx::default();
// 2026-09-13T12:35:03Z, a time that is not the time of this run.
let msgs = classify(&ev, 1_789_302_903, &mut ctx);
let payload = serde_json::to_vec(&fit(msgs[0].clone(), 4096).expect("a small row fits"))
.expect("serialises");
let value: serde_json::Value = serde_json::from_slice(&payload).expect("valid json");
assert_eq!(value["ts"], serde_json::json!(ROW_TS));
}
}