Skip to main content

max / makenotwork

sando-tui: per-run live-tail pane (Phase C) GateLogChunk events now route into a per-GateRunId ring buffer (line-aware appending; partial-line carryover across chunks) instead of spamming the main events log. A new tail pane shows the most recently active gate run's output with status word and a (run N of M) position marker; [ and ] cycle through up to 10 retained runs. GateRunId derives Ord/PartialOrd so a BTreeMap can iterate chronologically. GateStart and GateDone still appear in the events log; only GateLogChunk diverts. Empty / unknown-run / wrap-around / line-cap cases covered by ten new TUI tests (29 total).
Author: Max Johnson <me@maxj.phd> · 2026-06-04 02:34 UTC
Commit: e5928a488a2c259552d55945de0a49f84fe59e26
Parent: 2ca0e4b
2 files changed, +381 insertions, -15 deletions
@@ -305,8 +305,10 @@ impl<'r> sqlx::Decode<'r, Sqlite> for GateKind {
305 305 // ---------------------------------------------------------------------
306 306
307 307 /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk`
308 - /// → `GateDone` so client-side correlation is trivial.
309 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
308 + /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic
309 + /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which
310 + /// the TUI's run-buffer map relies on for chronological iteration.
311 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type)]
310 312 #[sqlx(transparent)]
311 313 #[serde(transparent)]
312 314 pub struct GateRunId(pub i64);
@@ -11,8 +11,11 @@
11 11 //! ├ events (WS /events) ────────────────────────────│
12 12 //! │ 02:55:39 backup_fetched 36MB │
13 13 //! │ 02:55:40 manual_confirm tier=a v=0.8.12 │
14 + //! ├ tail [17] a/0.9.6 cargo_test — in-flight ───────│
15 + //! │ running 1 test │
16 + //! │ test routes::tests::ok ... ok │
14 17 //! ├ status / keys ──────────────────────────────────│
15 - //! │ [p] promote [R] rollback [b] backup [c] confirm [r] refresh [q] quit │
18 + //! │ [p] promote [R] rollback [b] backup [c] confirm [[/]] tail [r] refresh [q] quit │
16 19 //! └─────────────────────────────────────────────────┘
17 20 //!
18 21 //! State is refreshed every 2s by a background task. Events arrive in real
@@ -27,10 +30,11 @@ use crossterm::terminal::{
27 30 use futures_util::StreamExt;
28 31 use ratatui::prelude::*;
29 32 use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Row, Table};
33 + use sando_daemon::domain::{GateKind, GateRunId, TierId, Version};
30 34 use sando_daemon::events::{Event, EventEnvelope};
31 35 use sando_daemon::outcome::{DeployFailureKind, GateBlocker, GateFailure, GateStatus, PassNote};
32 36 use serde::Deserialize;
33 - use std::collections::VecDeque;
37 + use std::collections::{BTreeMap, VecDeque};
34 38 use std::io;
35 39 use std::sync::{Arc, Mutex};
36 40 use std::time::Duration;
@@ -91,9 +95,86 @@ struct Shared {
91 95 ws_ok: bool,
92 96 selected: usize,
93 97 notice: Option<String>,
98 + /// Per-run live-tail buffers keyed by `GateRunId`. Populated by
99 + /// `GateStart` / `GateLogChunk` / `GateDone`. Bounded to the most
100 + /// recent `GATE_TAILS_CAP` runs (LRU by start time).
101 + gate_tails: BTreeMap<GateRunId, GateTail>,
102 + /// Run shown in the tail pane. Defaults to the most recently active
103 + /// run; updated as new `GateStart` events arrive.
104 + focus_run: Option<GateRunId>,
94 105 }
95 106
96 107 const EVENTS_CAP: usize = 200;
108 + const GATE_TAILS_CAP: usize = 10;
109 + const TAIL_LINES_CAP: usize = 200;
110 +
111 + /// Per-run live-tail state. Receives `GateLogChunk` bytes and presents
112 + /// them as a line-aware ring buffer. Chunks are NOT line-aligned at the
113 + /// transport — we buffer a trailing partial line across chunks so the UI
114 + /// only renders complete lines (and one trailing in-flight line, on
115 + /// `close`/finalize).
116 + struct GateTail {
117 + tier: TierId,
118 + version: Version,
119 + gate: GateKind,
120 + /// Complete lines, oldest first. Newest end of the deque is what the
121 + /// pane shows at the bottom.
122 + lines: VecDeque<String>,
123 + /// Trailing bytes after the last `\n` in any chunk so far. Flushed
124 + /// into `lines` on `finalize` (so the operator sees the last line
125 + /// even if the gate exited without a final newline).
126 + partial: String,
127 + status: TailStatus,
128 + }
129 +
130 + #[derive(Clone, Debug, PartialEq, Eq)]
131 + enum TailStatus {
132 + InFlight,
133 + Finished(String),
134 + }
135 +
136 + impl GateTail {
137 + fn new(tier: TierId, version: Version, gate: GateKind) -> Self {
138 + Self {
139 + tier,
140 + version,
141 + gate,
142 + lines: VecDeque::new(),
143 + partial: String::new(),
144 + status: TailStatus::InFlight,
145 + }
146 + }
147 +
148 + /// Append a chunk of stdout/stderr. Splits on `\n`, buffers the
149 + /// trailing partial line, caps the ring at `TAIL_LINES_CAP`.
150 + fn push_chunk(&mut self, text: &str) {
151 + let combined = std::mem::take(&mut self.partial) + text;
152 + let mut rest = combined.as_str();
153 + while let Some(idx) = rest.find('\n') {
154 + let (line, after) = rest.split_at(idx);
155 + self.push_line(line.trim_end_matches('\r').to_string());
156 + rest = &after[1..];
157 + }
158 + self.partial = rest.to_string();
159 + }
160 +
161 + fn push_line(&mut self, line: String) {
162 + if self.lines.len() >= TAIL_LINES_CAP {
163 + self.lines.pop_front();
164 + }
165 + self.lines.push_back(line);
166 + }
167 +
168 + /// Called when `GateDone` arrives. Flushes any trailing partial
169 + /// line and records the gate's final status word.
170 + fn finalize(&mut self, status_word: String) {
171 + let trailing = std::mem::take(&mut self.partial);
172 + if !trailing.is_empty() {
173 + self.push_line(trailing);
174 + }
175 + self.status = TailStatus::Finished(status_word);
176 + }
177 + }
97 178
98 179 impl Shared {
99 180 fn push_event(&mut self, line: String) {
@@ -102,6 +183,30 @@ impl Shared {
102 183 }
103 184 self.events.push_back(line);
104 185 }
186 +
187 + fn open_tail(&mut self, run_id: GateRunId, tier: TierId, version: Version, gate: GateKind) {
188 + if self.gate_tails.len() >= GATE_TAILS_CAP {
189 + // BTreeMap is sorted by GateRunId (monotonic from the daemon's
190 + // INSERT … RETURNING id), so the smallest key is the oldest run.
191 + if let Some((&oldest, _)) = self.gate_tails.iter().next() {
192 + self.gate_tails.remove(&oldest);
193 + }
194 + }
195 + self.gate_tails.insert(run_id, GateTail::new(tier, version, gate));
196 + self.focus_run = Some(run_id);
197 + }
198 +
199 + fn push_tail_chunk(&mut self, run_id: GateRunId, text: &str) {
200 + if let Some(t) = self.gate_tails.get_mut(&run_id) {
201 + t.push_chunk(text);
202 + }
203 + }
204 +
205 + fn finalize_tail(&mut self, run_id: GateRunId, status_word: String) {
206 + if let Some(t) = self.gate_tails.get_mut(&run_id) {
207 + t.finalize(status_word);
208 + }
209 + }
105 210 }
106 211
107 212 // ---------- main ----------
@@ -172,8 +277,7 @@ async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) {
172 277 while let Some(msg) = socket.next().await {
173 278 match msg {
174 279 Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => {
175 - let line = format_event(&t).unwrap_or_else(|| t.to_string());
176 - shared.lock().unwrap().push_event(line);
280 + dispatch_ws_frame(&shared, &t);
177 281 }
178 282 Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => break,
179 283 Err(_) => break,
@@ -200,6 +304,53 @@ fn ws_url_from(daemon: &str) -> String {
200 304 + "/events"
201 305 }
202 306
307 + /// Route an incoming WS frame: gate live-tail events update the per-run
308 + /// buffer, every other event becomes one line in the events ring.
309 + ///
310 + /// `GateLogChunk` does NOT push into the events ring — a busy gate emits
311 + /// hundreds of chunks per second, which would evict every other event in
312 + /// under a minute. The chunk text only lives in the per-run tail buffer.
313 + fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
314 + if let Some(line) = format_lagged(raw) {
315 + shared.lock().unwrap().push_event(line);
316 + return;
317 + }
318 + let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else {
319 + // Unparseable frame — surface verbatim so the operator can see it.
320 + shared.lock().unwrap().push_event(raw.to_string());
321 + return;
322 + };
323 + match &env.event {
324 + Event::GateStart { run_id, tier, version, gate } => {
325 + let mut g = shared.lock().unwrap();
326 + g.open_tail(*run_id, tier.clone(), version.clone(), *gate);
327 + let line = format_event_line(&env);
328 + g.push_event(line);
329 + }
330 + Event::GateLogChunk { run_id, text, .. } => {
331 + shared.lock().unwrap().push_tail_chunk(*run_id, text);
332 + }
333 + Event::GateDone { run_id, outcome, .. } => {
334 + let status_word = outcome.status_str().to_string();
335 + let mut g = shared.lock().unwrap();
336 + g.finalize_tail(*run_id, status_word);
337 + let line = format_event_line(&env);
338 + g.push_event(line);
339 + }
340 + _ => {
341 + let line = format_event_line(&env);
342 + shared.lock().unwrap().push_event(line);
343 + }
344 + }
345 + }
346 +
347 + fn format_event_line(env: &EventEnvelope) -> String {
348 + let time = env.at.format("%H:%M:%S").to_string();
349 + let kind = event_kind_str(&env.event);
350 + let body = format_event_body(&env.event);
351 + format!("{time} {kind} {body}")
352 + }
353 +
203 354 /// Render one event envelope into a human-readable single line.
204 355 ///
205 356 /// Step 5: deserializes the daemon's `EventEnvelope` directly via the
@@ -208,15 +359,13 @@ fn ws_url_from(daemon: &str) -> String {
208 359 /// frame is a daemon-emitted ad-hoc envelope that doesn't match the
209 360 /// `Event` enum (it's `{"kind":"lagged","skipped":N}`), so we handle it
210 361 /// with a one-shot probe before the typed parse.
362 + #[cfg(test)]
211 363 fn format_event(json: &str) -> Option<String> {
212 364 if let Some(line) = format_lagged(json) {
213 365 return Some(line);
214 366 }
215 367 let env: EventEnvelope = serde_json::from_str(json).ok()?;
216 - let time = env.at.format("%H:%M:%S").to_string();
217 - let kind = event_kind_str(&env.event);
218 - let body = format_event_body(&env.event);
219 - Some(format!("{time} {kind} {body}"))
368 + Some(format_event_line(&env))
220 369 }
221 370
222 371 fn format_lagged(json: &str) -> Option<String> {
@@ -261,9 +410,9 @@ fn format_event_body(e: &Event) -> String {
261 410 Event::GateStart { run_id, tier, version, gate } =>
262 411 format!("[{run_id}] {tier}/{version} {gate} start"),
263 412 Event::GateLogChunk { run_id, seq, text } => {
264 - // Live tail line — keep to one line, strip trailing newline,
265 - // truncate aggressively. Per-line scrollback lives in the
266 - // on-disk log (Phase 6 ring-buffer pane is future work).
413 + // Single-line summary used by `format_event` (test path only);
414 + // production rendering routes chunks through `dispatch_ws_frame`
415 + // into the per-run tail buffer, never the events ring.
267 416 let one = text.lines().next().unwrap_or("").trim_end();
268 417 let truncated: String = one.chars().take(160).collect();
269 418 format!("[{run_id} #{seq}] {truncated}")
@@ -447,6 +596,14 @@ fn ui_loop<B: Backend>(
447 596 let _ = action_tx.try_send(Action::Confirm { tier: t.name });
448 597 }
449 598 }
599 + KeyCode::Char('[') => {
600 + let mut g = shared.lock().unwrap();
601 + g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, -1);
602 + }
603 + KeyCode::Char(']') => {
604 + let mut g = shared.lock().unwrap();
605 + g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, 1);
606 + }
450 607 _ => {}
451 608 }
452 609 }
@@ -517,6 +674,7 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
517 674 Constraint::Length(3), // header
518 675 Constraint::Length(10), // tiers
519 676 Constraint::Min(4), // events
677 + Constraint::Length(8), // gate tail
520 678 Constraint::Length(2), // status
521 679 ])
522 680 .split(f.area());
@@ -624,8 +782,40 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
624 782 );
625 783 f.render_widget(events_block, area);
626 784
785 + // Gate tail (per-run live log buffer).
786 + let tail_area = chunks[3];
787 + let tail = g.focus_run.and_then(|id| g.gate_tails.get(&id).map(|t| (id, t)));
788 + let tail_widget = match tail {
789 + Some((id, t)) => {
790 + let title_state = match &t.status {
791 + TailStatus::InFlight => "in-flight".to_string(),
792 + TailStatus::Finished(s) => s.clone(),
793 + };
794 + let title = format!(
795 + "tail [{}] {}/{} {} — {title_state} ([) prev / (]) next {} of {}",
796 + id, t.tier, t.version, t.gate,
797 + tail_position(&g.gate_tails, id), g.gate_tails.len(),
798 + );
799 + let visible = tail_area.height.saturating_sub(2) as usize;
800 + let items: Vec<ListItem> = t.lines
801 + .iter()
802 + .rev()
803 + .take(visible)
804 + .rev()
805 + .map(|l| ListItem::new(truncate(l, 200)))
806 + .collect();
807 + List::new(items).block(Block::default().title(title).borders(Borders::ALL))
808 + }
809 + None => {
810 + let body = "no recent gate runs";
811 + List::new(vec![ListItem::new(body)])
812 + .block(Block::default().title("tail").borders(Borders::ALL))
813 + }
814 + };
815 + f.render_widget(tail_widget, tail_area);
816 +
627 817 // Status / keys
628 - let keys = "[p] promote [R] rollback [b] backup [c] confirm [↑↓] select [r] refresh [q] quit";
818 + let keys = "[p] promote [R] rollback [b] backup [c] confirm [↑↓] select [[/]] tail [r] refresh [q] quit";
629 819 let status = if let Some(n) = &g.notice {
630 820 format!("{n} {keys}")
631 821 } else if let Some(e) = &g.last_err {
@@ -633,7 +823,30 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
633 823 } else {
634 824 keys.into()
635 825 };
636 - f.render_widget(Paragraph::new(status), chunks[3]);
826 + f.render_widget(Paragraph::new(status), chunks[4]);
827 + }
828 +
829 + /// 1-based index of `id` within the sorted tail map; 0 if not present.
830 + fn tail_position(tails: &BTreeMap<GateRunId, GateTail>, id: GateRunId) -> usize {
831 + tails.keys().position(|k| *k == id).map(|i| i + 1).unwrap_or(0)
832 + }
833 +
834 + /// Move `focus_run` by `step` positions through the sorted run map.
835 + /// Wraps at both ends. Returns the new focus, or `None` if the map is
836 + /// empty.
837 + fn cycle_focus(
838 + tails: &BTreeMap<GateRunId, GateTail>,
839 + current: Option<GateRunId>,
840 + step: i32,
841 + ) -> Option<GateRunId> {
842 + if tails.is_empty() { return None; }
843 + let keys: Vec<GateRunId> = tails.keys().copied().collect();
844 + let idx = current
845 + .and_then(|c| keys.iter().position(|k| *k == c))
846 + .unwrap_or(keys.len().saturating_sub(1));
847 + let len = keys.len() as i32;
848 + let new_idx = ((idx as i32 + step) % len + len) % len;
849 + Some(keys[new_idx as usize])
637 850 }
638 851
639 852 // ---------- tests ----------
@@ -842,4 +1055,155 @@ mod tests {
842 1055 assert_eq!(sh.events.front().unwrap(), "e50");
843 1056 assert_eq!(sh.events.back().unwrap(), &format!("e{}", EVENTS_CAP + 49));
844 1057 }
1058 +
1059 + // ----- gate tail buffer (Phase C) -----
1060 +
1061 + fn tail_fixture() -> GateTail {
1062 + GateTail::new(TierId::new("a"), "0.9.6".parse().unwrap(), GateKind::CargoTest)
1063 + }
1064 +
1065 + #[test]
1066 + fn gate_tail_splits_chunks_on_newlines() {
1067 + let mut t = tail_fixture();
1068 + t.push_chunk("one\ntwo\n");
1069 + assert_eq!(t.lines.len(), 2);
1070 + assert_eq!(t.lines[0], "one");
1071 + assert_eq!(t.lines[1], "two");
1072 + assert!(t.partial.is_empty());
1073 + }
1074 +
1075 + #[test]
1076 + fn gate_tail_buffers_partial_line_across_chunks() {
1077 + // Chunks arrive at tokio read boundaries — a single logical line
1078 + // can span multiple chunks. The tail must wait for `\n` before
1079 + // committing.
1080 + let mut t = tail_fixture();
1081 + t.push_chunk("Compi");
1082 + t.push_chunk("ling foo");
1083 + assert!(t.lines.is_empty());
1084 + assert_eq!(t.partial, "Compiling foo");
1085 + t.push_chunk(" v0.1.0\nDone\n");
1086 + assert_eq!(t.lines.len(), 2);
1087 + assert_eq!(t.lines[0], "Compiling foo v0.1.0");
1088 + assert_eq!(t.lines[1], "Done");
1089 + }
1090 +
1091 + #[test]
1092 + fn gate_tail_strips_carriage_returns() {
1093 + // Cargo emits `\r\n` on Windows-builds; treat the `\r` as part of
1094 + // the line terminator so the pane doesn't render visible `^M`s.
1095 + let mut t = tail_fixture();
1096 + t.push_chunk("hello\r\nworld\r\n");
1097 + assert_eq!(t.lines[0], "hello");
1098 + assert_eq!(t.lines[1], "world");
1099 + }
1100 +
1101 + #[test]
1102 + fn gate_tail_caps_lines_at_tail_lines_cap() {
1103 + let mut t = tail_fixture();
1104 + for i in 0..(TAIL_LINES_CAP + 50) {
1105 + t.push_chunk(&format!("line {i}\n"));
1106 + }
1107 + assert_eq!(t.lines.len(), TAIL_LINES_CAP);
1108 + assert_eq!(t.lines.front().unwrap(), &format!("line {}", 50));
1109 + }
1110 +
1111 + #[test]
1112 + fn gate_tail_finalize_flushes_trailing_partial() {
1113 + // If the gate's last write didn't end in `\n` (panic mid-line,
1114 + // SIGKILL), the partial-buffered tail must still surface on
1115 + // finalize.
1116 + let mut t = tail_fixture();
1117 + t.push_chunk("partial without newline");
1118 + assert!(t.lines.is_empty());
1119 + t.finalize("failed".into());
1120 + assert_eq!(t.lines.len(), 1);
1121 + assert_eq!(t.lines[0], "partial without newline");
1122 + assert_eq!(t.status, TailStatus::Finished("failed".into()));
1123 + }
1124 +
1125 + #[test]
1126 + fn shared_open_tail_evicts_oldest_at_capacity() {
1127 + let mut sh = Shared::default();
1128 + for i in 0..(GATE_TAILS_CAP + 3) as i64 {
1129 + sh.open_tail(
1130 + GateRunId(i + 1),
1131 + TierId::new("a"),
1132 + "0.9.6".parse().unwrap(),
1133 + GateKind::CargoTest,
1134 + );
1135 + }
1136 + assert_eq!(sh.gate_tails.len(), GATE_TAILS_CAP);
1137 + // The first 3 (run_id 1, 2, 3) were evicted; the smallest key
1138 + // remaining should be 4.
1139 + assert_eq!(*sh.gate_tails.keys().next().unwrap(), GateRunId(4));
1140 + // focus_run follows the most recent insertion.
1141 + assert_eq!(sh.focus_run, Some(GateRunId((GATE_TAILS_CAP + 3) as i64)));
1142 + }
1143 +
1144 + #[test]
1145 + fn shared_push_tail_chunk_is_noop_for_unknown_run() {
1146 + // GateLogChunk can arrive before GateStart if a frame lags; just
1147 + // drop it rather than synthesizing a header-less run.
1148 + let mut sh = Shared::default();
1149 + sh.push_tail_chunk(GateRunId(99), "orphaned\n");
1150 + assert!(sh.gate_tails.is_empty());
1151 + }
1152 +
1153 + #[test]
1154 + fn cycle_focus_wraps_in_both_directions() {
1155 + let mut sh = Shared::default();
1156 + for i in 1..=3i64 {
1157 + sh.open_tail(
1158 + GateRunId(i),
1159 + TierId::new("a"),
1160 + "0.9.6".parse().unwrap(),
1161 + GateKind::CargoTest,
1162 + );
1163 + }
1164 + // Currently focused on the most recent (run 3).
1165 + assert_eq!(sh.focus_run, Some(GateRunId(3)));
1166 + // Forward wraps to the smallest.
1167 + let next = cycle_focus(&sh.gate_tails, sh.focus_run, 1);
1168 + assert_eq!(next, Some(GateRunId(1)));
1169 + // Backward from the smallest wraps to the largest.
1170 + let prev = cycle_focus(&sh.gate_tails, Some(GateRunId(1)), -1);
1171 + assert_eq!(prev, Some(GateRunId(3)));
1172 + }
1173 +
1174 + #[test]
1175 + fn cycle_focus_handles_empty_map() {
1176 + let sh = Shared::default();
1177 + assert_eq!(cycle_focus(&sh.gate_tails, None, 1), None);
1178 + assert_eq!(cycle_focus(&sh.gate_tails, Some(GateRunId(7)), -1), None);
1179 + }
1180 +
1181 + #[test]
1182 + fn dispatch_ws_routes_gate_log_chunks_into_tail_not_events() {
1183 + // The contract: a busy gate (hundreds of GateLogChunks) must NOT
1184 + // evict other events from the main ring. Only GateStart and
1185 + // GateDone get an events-log line.
1186 + let sh = Arc::new(Mutex::new(Shared::default()));
1187 + let start = r#"{"at":"2026-06-01T02:55:39Z","kind":"gate_start","run_id":17,"tier":"a","version":"0.9.6","gate":"cargo_test"}"#;
1188 + dispatch_ws_frame(&sh, start);
1189 + for i in 0..50 {
1190 + let chunk = format!(
1191 + r#"{{"at":"2026-06-01T02:55:39Z","kind":"gate_log_chunk","run_id":17,"seq":{i},"text":"line {i}\n"}}"#
1192 + );
1193 + dispatch_ws_frame(&sh, &chunk);
1194 + }
1195 + let done = r#"{"at":"2026-06-01T02:55:40Z","kind":"gate_done","run_id":17,"tier":"a","version":"0.9.6","gate":"cargo_test","outcome":{"status":{"kind":"passed","note":{"kind":"tests_passed","duration_s":1}}}}"#;
1196 + dispatch_ws_frame(&sh, done);
1197 +
1198 + let g = sh.lock().unwrap();
1199 + // Two lines in the events ring: start + done. Zero chunk lines.
1200 + assert_eq!(g.events.len(), 2);
1201 + assert!(g.events.front().unwrap().contains("gate_start"));
1202 + assert!(g.events.back().unwrap().contains("gate_done"));
1203 + // 50 lines in the tail buffer.
1204 + let tail = g.gate_tails.get(&GateRunId(17)).expect("tail entry");
1205 + assert_eq!(tail.lines.len(), 50);
1206 + assert_eq!(tail.lines.back().unwrap(), "line 49");
1207 + assert_eq!(tail.status, TailStatus::Finished("passed".into()));
1208 + }
845 1209 }