| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
}
|