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:
parent
349f7096ba
commit
053340128b
5 changed files with 206 additions and 38 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue