pub mod collector;
pub use collector::{collect_all, collect_session, LifecycleCollector, TURN_TASK};
use std::time::Duration;
use crate::tty::widgets::Level;
pub type TaskId = &'static str;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Measure {
pub done: u64,
pub total: Option<u64>,
}
impl Measure {
#[must_use]
pub fn of(done: u64) -> Self {
Self { done, total: None }
}
#[must_use]
pub fn out_of(done: u64, total: u64) -> Self {
Self {
done,
total: Some(total),
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
pub level: Level,
pub glyph: &'static str,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Frame {
pub label: String,
pub elapsed: Duration,
pub units: u64,
}
impl Frame {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
elapsed: Duration::ZERO,
units: 0,
}
}
#[must_use]
pub fn after(mut self, elapsed: Duration) -> Self {
self.elapsed = elapsed;
self
}
#[must_use]
pub fn with_units(mut self, units: u64) -> Self {
self.units = units;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Durable {
Started {
label: String,
},
Snapshot(Measure),
Note(Note),
Finished(Outcome),
}
pub trait ProgressSink: Send {
fn frame(&mut self, task: TaskId, at_ms: u64, frame: &Frame);
fn record(&mut self, task: TaskId, at_ms: u64, event: &Durable);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NullSink;
impl ProgressSink for NullSink {
fn frame(&mut self, _task: TaskId, _at_ms: u64, _frame: &Frame) {}
fn record(&mut self, _task: TaskId, _at_ms: u64, _event: &Durable) {}
}
#[derive(Debug, Default, Clone)]
pub struct LatestFrame {
latest: Option<(TaskId, u64, Frame)>,
}
impl LatestFrame {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn peek(&self) -> Option<&(TaskId, u64, Frame)> {
self.latest.as_ref()
}
}
impl ProgressSink for LatestFrame {
fn frame(&mut self, task: TaskId, at_ms: u64, frame: &Frame) {
self.latest = Some((task, at_ms, frame.clone()));
}
fn record(&mut self, _task: TaskId, _at_ms: u64, _event: &Durable) {}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Commit {
Started {
label: String,
},
Advanced(Measure),
Note(Note),
Finished(Outcome),
}
#[derive(Debug, Default, Clone)]
pub struct Scrollback {
committed: Vec<Commit>,
high_water: Option<u64>,
sealed: bool,
}
impl Scrollback {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn offer(&mut self, event: &Durable) -> Option<Commit> {
let commit = self.commit_of(event)?;
if matches!(commit, Commit::Finished(_)) {
self.sealed = true;
}
if let Commit::Advanced(m) = &commit {
self.high_water = Some(m.done);
}
self.committed.push(commit.clone());
Some(commit)
}
fn commit_of(&self, event: &Durable) -> Option<Commit> {
if self.sealed {
return None;
}
match event {
Durable::Started { label } => Some(Commit::Started {
label: label.clone(),
}),
Durable::Snapshot(m) => match self.high_water {
Some(hw) if m.done <= hw => None,
_ => Some(Commit::Advanced(*m)),
},
Durable::Note(n) => Some(Commit::Note(n.clone())),
Durable::Finished(o) => Some(Commit::Finished(*o)),
}
}
#[must_use]
pub fn committed(&self) -> &[Commit] {
&self.committed
}
#[must_use]
pub fn is_sealed(&self) -> bool {
self.sealed
}
}
#[must_use]
pub fn from_lifecycle(event: &crate::lifecycle::LifecycleEvent) -> Option<Durable> {
use crate::lifecycle::LifecycleEvent as L;
match event {
L::TurnStarted => Some(Durable::Started {
label: "turn".to_string(),
}),
L::TurnCompleted => Some(Durable::Finished(Outcome::Completed)),
L::TurnFailed { .. } => Some(Durable::Finished(Outcome::Failed)),
L::TurnCancelled => Some(Durable::Finished(Outcome::Cancelled)),
L::SessionStarted { .. }
| L::Waiting
| L::Thinking
| L::ToolActivity { .. }
| L::Blocked
| L::Unblocked
| L::SessionEnded => None,
}
}
impl ProgressSink for Scrollback {
fn frame(&mut self, _task: TaskId, _at_ms: u64, _frame: &Frame) {}
fn record(&mut self, _task: TaskId, _at_ms: u64, event: &Durable) {
let _ = self.offer(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
const TASK: TaskId = "compaction";
fn sb_offer(sb: &mut Scrollback, d: Durable) -> Option<Commit> {
sb.offer(&d)
}
#[test]
fn frames_have_no_path_to_scrollback() {
let mut sb = Scrollback::new();
for t in 0..500 {
sb.frame(TASK, t, &Frame::new(format!("working {t}")));
}
assert!(
sb.committed().is_empty(),
"500 frames committed {:?}",
sb.committed()
);
}
#[test]
fn and_a_durable_in_the_same_stream_does_commit() {
let mut sb = Scrollback::new();
for t in 0..50 {
sb.frame(TASK, t, &Frame::new("spin"));
}
sb.record(TASK, 50, &Durable::Snapshot(Measure::of(1)));
assert_eq!(
sb.committed(),
&[Commit::Advanced(Measure::of(1))],
"a real measurement must still get through the same sink"
);
}
#[test]
fn only_advancing_snapshots_commit() {
let mut sb = Scrollback::new();
assert!(sb_offer(&mut sb, Durable::Snapshot(Measure::of(5))).is_some());
assert_eq!(
sb_offer(&mut sb, Durable::Snapshot(Measure::of(5))),
None,
"a repeat of the same measurement is not progress"
);
assert_eq!(
sb_offer(&mut sb, Durable::Snapshot(Measure::of(3))),
None,
"a regression must never commit — scrollback cannot go backwards"
);
assert!(
sb_offer(&mut sb, Durable::Snapshot(Measure::of(6))).is_some(),
"and the next genuine advance still commits"
);
}
#[test]
fn nothing_commits_after_terminal_state() {
let mut sb = Scrollback::new();
sb_offer(&mut sb, Durable::Finished(Outcome::Completed));
assert!(sb.is_sealed());
for late in [
Durable::Snapshot(Measure::of(99)),
Durable::Note(Note {
level: Level::Warn,
glyph: "⚠",
text: "late".into(),
}),
Durable::Finished(Outcome::Failed),
] {
assert_eq!(
sb.offer(&late),
None,
"nothing may commit after the end: {late:?}"
);
}
sb.frame(TASK, 9, &Frame::new("late spin"));
assert_eq!(sb.committed().len(), 1, "exactly the one ending");
}
#[test]
fn the_committed_story_is_the_monotone_subsequence_plus_terminal_state() {
let mut sb = Scrollback::new();
let mut at = 0u64;
sb.record(
TASK,
at,
&Durable::Started {
label: "compacting".into(),
},
);
for done in [1u64, 1, 2, 2, 2, 5, 4, 5, 9] {
for _ in 0..7 {
at += 1;
sb.frame(TASK, at, &Frame::new(format!("t{at}")));
}
at += 1;
sb.record(TASK, at, &Durable::Snapshot(Measure::of(done)));
}
at += 1;
sb.record(TASK, at, &Durable::Finished(Outcome::Completed));
assert_eq!(
sb.committed(),
&[
Commit::Started {
label: "compacting".into()
},
Commit::Advanced(Measure::of(1)),
Commit::Advanced(Measure::of(2)),
Commit::Advanced(Measure::of(5)),
Commit::Advanced(Measure::of(9)),
Commit::Finished(Outcome::Completed),
],
"committed scrollback must be the strictly-increasing subsequence \
(1,2,5,9 — not the repeats, not the 4) bracketed by lifecycle"
);
}
#[test]
fn nothing_may_add_a_conversion_from_a_frame_to_a_commit() {
let forbidden = [
concat!("From<", "Frame>"),
concat!("From<&", "Frame>"),
concat!("Frame)", " -> Commit"),
concat!("Frame)", " -> Durable"),
concat!("Frame)", " -> Option<Commit>"),
];
for (name, src) in [
("progress/mod.rs", include_str!("mod.rs")),
(
"tty/progress_sink.rs",
include_str!("../tty/progress_sink.rs"),
),
] {
for needle in forbidden {
assert!(
!src.contains(needle),
"{name} declares `{needle}` — that reopens the scrollback \
boundary this module exists to close. A frame is view \
state; if a caller needs a durable line, it must build a \
`Durable` and say what the line MEANS (#1864)."
);
}
}
}
#[test]
fn a_note_commits_and_speaks_the_existing_level_vocabulary() {
let mut sb = Scrollback::new();
let note = Note {
level: Level::Warn,
glyph: "⚠",
text: "context trimmed".into(),
};
assert_eq!(
sb_offer(&mut sb, Durable::Note(note.clone())),
Some(Commit::Note(note))
);
}
#[test]
fn a_frame_carries_no_rendering_and_no_glyph() {
let src = include_str!("mod.rs");
let decl_start = src
.find(concat!("pub struct ", "Frame {"))
.expect("Frame is declared here");
let decl = &src[decl_start..decl_start + src[decl_start..].find('}').unwrap()];
for banned in ["glyph", "frame_index", "rendered", "fitted", "ansi", "cols"] {
assert!(
!decl.contains(banned),
"Frame declares `{banned}` — a frame carries measurable facts, \
never how they look. The view composes and fits (#1895)."
);
}
for fact in ["label", "elapsed", "units"] {
assert!(decl.contains(fact), "Frame must still carry `{fact}`");
}
}
#[test]
fn and_the_frame_declaration_scan_is_looking_at_something() {
let src = include_str!("mod.rs");
let decl_start = src.find(concat!("pub struct ", "Frame {")).unwrap();
let decl = &src[decl_start..decl_start + src[decl_start..].find('}').unwrap()];
assert!(
decl.len() > 80,
"the scanned declaration is implausibly short ({} bytes) — the \
omission test above would be vacuous",
decl.len()
);
assert!(
decl.contains("Duration"),
"elapsed is a Duration, not a string"
);
}
#[test]
fn a_high_rate_producer_grows_neither_sink() {
let mut sb = Scrollback::new();
let mut latest = LatestFrame::new();
for t in 0..10_000u64 {
sb.frame(TASK, t, &Frame::new(format!("f{t}")));
latest.frame(TASK, t, &Frame::new(format!("f{t}")));
sb.record(TASK, t, &Durable::Snapshot(Measure::of(1)));
}
assert_eq!(
sb.committed().len(),
1,
"10k frames + 10k non-advancing snapshots must commit exactly the \
one genuine advance, not a backlog"
);
let (_, at, frame) = latest.peek().expect("a frame was published");
assert_eq!(
(*at, frame.label.as_str()),
(9_999, "f9999"),
"the coalescing sink keeps the NEWEST frame, not the oldest"
);
}
#[test]
fn the_lifecycle_seam_maps_onto_durable_progress() {
use crate::lifecycle::LifecycleEvent as L;
assert_eq!(
from_lifecycle(&L::TurnStarted),
Some(Durable::Started {
label: "turn".into()
})
);
assert_eq!(
from_lifecycle(&L::TurnCompleted),
Some(Durable::Finished(Outcome::Completed))
);
assert_eq!(
from_lifecycle(&L::TurnFailed { reason: None }),
Some(Durable::Finished(Outcome::Failed))
);
assert_eq!(
from_lifecycle(&L::TurnCancelled),
Some(Durable::Finished(Outcome::Cancelled))
);
}
#[test]
fn transient_lifecycle_states_are_not_durable_progress() {
use crate::lifecycle::LifecycleEvent as L;
for e in [
L::Thinking,
L::ToolActivity {
tool: "run_command".into(),
},
L::Waiting,
L::Blocked,
L::Unblocked,
L::SessionStarted {
session_id: "s".into(),
},
L::SessionEnded,
] {
assert_eq!(from_lifecycle(&e), None, "{e:?} must not commit");
}
}
#[test]
fn a_replayed_lifecycle_stream_commits_only_the_turn_story() {
use crate::lifecycle::LifecycleEvent as L;
let mut sb = Scrollback::new();
let stream = [
L::SessionStarted {
session_id: "s".into(),
},
L::TurnStarted,
L::Thinking,
L::ToolActivity {
tool: "read_file".into(),
},
L::Thinking,
L::ToolActivity {
tool: "edit_file".into(),
},
L::Blocked,
L::Unblocked,
L::Thinking,
L::TurnCompleted,
L::Waiting,
];
for (i, e) in stream.iter().enumerate() {
if let Some(d) = from_lifecycle(e) {
sb.record(TASK, i as u64, &d);
}
}
assert_eq!(
sb.committed(),
&[
Commit::Started {
label: "turn".into()
},
Commit::Finished(Outcome::Completed),
],
"eleven lifecycle events, two committed lines"
);
}
#[test]
fn the_null_sink_swallows_every_event() {
let mut sink = NullSink;
for t in 0..10 {
sink.frame(TASK, t, &Frame::new("x"));
}
sink.record(TASK, 10, &Durable::Finished(Outcome::Cancelled));
}
#[test]
fn the_sink_is_object_safe() {
let mut sb = Scrollback::new();
let dynamic: &mut dyn ProgressSink = &mut sb;
dynamic.frame(TASK, 0, &Frame::new("x"));
dynamic.record(TASK, 1, &Durable::Finished(Outcome::Completed));
assert_eq!(sb.committed().len(), 1);
}
}