feat(#1540): expose per-event ts on live frame + history rows

This commit is contained in:
damocles 2026-06-10 14:01:35 +02:00 committed by mara
commit 50e0ef9b46

View file

@ -169,6 +169,23 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct BusEvent { pub struct BusEvent {
pub seq: u64, pub seq: u64,
/// Unix seconds at emit time. Serialized as a sibling of the `kind`
/// tag so the agent terminal can render turn start/end times (and
/// turn duration) on the live stream; history rows carry the same
/// `ts` field sourced from the persisted `events.ts` column, so the
/// renderer reads `ts` identically for live + scrollback.
pub ts: i64,
#[serde(flatten)]
pub event: LiveEvent,
}
/// A persisted event paired with its stored unix-seconds timestamp.
/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape
/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts`
/// identically whether an event arrives live or is replayed from history.
#[derive(Debug, Clone, Serialize)]
pub struct StoredEvent {
pub ts: i64,
#[serde(flatten)] #[serde(flatten)]
pub event: LiveEvent, pub event: LiveEvent,
} }
@ -281,7 +298,7 @@ impl EventStore {
Ok(()) Ok(())
} }
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> { fn recent(&self, limit: usize) -> rusqlite::Result<Vec<StoredEvent>> {
let (events, _, _) = self.page(None, limit)?; let (events, _, _) = self.page(None, limit)?;
Ok(events) Ok(events)
} }
@ -293,43 +310,52 @@ impl EventStore {
&self, &self,
before_id: Option<i64>, before_id: Option<i64>,
limit: usize, limit: usize,
) -> rusqlite::Result<(Vec<LiveEvent>, Option<i64>, bool)> { ) -> rusqlite::Result<(Vec<StoredEvent>, Option<i64>, bool)> {
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX); let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
// Fetch one extra row so we can tell whether more exist. // Fetch one extra row so we can tell whether more exist.
let fetch = limit_i.saturating_add(1); let fetch = limit_i.saturating_add(1);
let rows: Vec<(i64, LiveEvent)> = if let Some(bid) = before_id { // `ts` is the persisted emit-time unix-seconds stamp; carried out
// alongside each event so history replay shows the same turn
// start/end times the live stream did.
let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, payload_json FROM events "SELECT id, ts, payload_json FROM events
WHERE id < ?1 WHERE id < ?1
ORDER BY id DESC ORDER BY id DESC
LIMIT ?2", LIMIT ?2",
)?; )?;
stmt.query_map(params![bid, fetch], |row| { stmt.query_map(params![bid, fetch], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get(0)?;
let s: String = row.get(1)?; let ts: i64 = row.get(1)?;
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e))) let s: String = row.get(2)?;
Ok(serde_json::from_str::<LiveEvent>(&s)
.ok()
.map(|event| (id, StoredEvent { ts, event })))
})? })?
.flatten() .flatten()
.flatten() .flatten()
.collect() .collect()
} else { } else {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, payload_json FROM events "SELECT id, ts, payload_json FROM events
ORDER BY id DESC ORDER BY id DESC
LIMIT ?1", LIMIT ?1",
)?; )?;
stmt.query_map(params![fetch], |row| { stmt.query_map(params![fetch], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get(0)?;
let s: String = row.get(1)?; let ts: i64 = row.get(1)?;
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e))) let s: String = row.get(2)?;
Ok(serde_json::from_str::<LiveEvent>(&s)
.ok()
.map(|event| (id, StoredEvent { ts, event })))
})? })?
.flatten() .flatten()
.flatten() .flatten()
.collect() .collect()
}; };
let has_more = rows.len() > limit; let has_more = rows.len() > limit;
let mut rows: Vec<(i64, LiveEvent)> = rows.into_iter().take(limit).collect(); let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect();
rows.reverse(); // oldest first rows.reverse(); // oldest first
let min_id = rows.first().map(|(id, _)| *id); let min_id = rows.first().map(|(id, _)| *id);
let events = rows.into_iter().map(|(_, e)| e).collect(); let events = rows.into_iter().map(|(_, e)| e).collect();
@ -1003,6 +1029,7 @@ impl Bus {
} }
let envelope = BusEvent { let envelope = BusEvent {
seq: self.next_seq(), seq: self.next_seq(),
ts: now_unix(),
event, event,
}; };
// Lagged subscribers drop events — fine; the UI is a tail, not a log. // Lagged subscribers drop events — fine; the UI is a tail, not a log.
@ -1018,7 +1045,7 @@ impl Bus {
/// Drives the terminal pre-fill when the operator opens the agent /// Drives the terminal pre-fill when the operator opens the agent
/// page; without a store (db open failed) this is empty. /// page; without a store (db open failed) this is empty.
#[must_use] #[must_use]
pub fn history(&self) -> Vec<LiveEvent> { pub fn history(&self) -> Vec<StoredEvent> {
let Some(store) = &self.store else { let Some(store) = &self.store else {
return Vec::new(); return Vec::new();
}; };
@ -1035,7 +1062,7 @@ impl Bus {
&self, &self,
before_id: Option<i64>, before_id: Option<i64>,
limit: usize, limit: usize,
) -> (Vec<LiveEvent>, Option<i64>, bool) { ) -> (Vec<StoredEvent>, Option<i64>, bool) {
let Some(store) = &self.store else { let Some(store) = &self.store else {
return (Vec::new(), None, false); return (Vec::new(), None, false);
}; };
@ -1051,9 +1078,41 @@ impl Default for Bus {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort}; use super::{
BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage,
is_valid_effort,
};
use serde_json::json; use serde_json::json;
#[test]
fn stored_event_serializes_ts_beside_kind() {
// History-row wire shape: `ts` is a flattened sibling of `kind`,
// which is what the agent terminal reads to time turn boundaries.
let v = serde_json::to_value(StoredEvent {
ts: 1_700_000_000,
event: LiveEvent::Note { text: "hi".into() },
})
.unwrap();
assert_eq!(v["ts"], 1_700_000_000_i64);
assert_eq!(v["kind"], "note");
assert_eq!(v["text"], "hi");
}
#[test]
fn bus_event_serializes_ts_and_seq_beside_kind() {
// Live SSE frame: same `ts` sibling as history (plus `seq`), so the
// renderer is path-agnostic between live + scrollback.
let v = serde_json::to_value(BusEvent {
seq: 7,
ts: 1_700_000_000,
event: LiveEvent::Note { text: "yo".into() },
})
.unwrap();
assert_eq!(v["seq"], 7);
assert_eq!(v["ts"], 1_700_000_000_i64);
assert_eq!(v["kind"], "note");
}
#[test] #[test]
fn effort_validation_accepts_only_known_levels() { fn effort_validation_accepts_only_known_levels() {
for level in EFFORT_LEVELS { for level in EFFORT_LEVELS {