use std::sync::{Arc, Mutex, PoisonError};
use super::{Durable, ProgressSink, Scrollback, TaskId};
use crate::lifecycle::{subscribe, subscribe_session, LifecycleEnvelope, Subscription};
pub const TURN_TASK: TaskId = "turn";
#[derive(Debug, Default)]
struct State {
current: Scrollback,
completed_turns: u64,
recorded: u64,
}
#[derive(Debug, Clone, Default)]
pub struct LifecycleCollector {
state: Arc<Mutex<State>>,
}
impl LifecycleCollector {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn observe(&self, envelope: &LifecycleEnvelope) {
let Some(durable) = super::from_lifecycle(&envelope.event) else {
return;
};
let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if matches!(durable, Durable::Started { .. }) {
st.current = Scrollback::new();
}
st.recorded += 1;
st.current.record(TURN_TASK, 0, &durable);
if st.current.is_sealed() {
st.completed_turns += 1;
}
}
#[must_use]
pub fn committed(&self) -> Vec<super::Commit> {
self.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.current
.committed()
.to_vec()
}
#[must_use]
pub fn completed_turns(&self) -> u64 {
self.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.completed_turns
}
#[must_use]
pub fn recorded(&self) -> u64 {
self.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.recorded
}
}
#[must_use]
pub fn collect_all() -> (LifecycleCollector, Subscription) {
let collector = LifecycleCollector::new();
let sub = {
let c = collector.clone();
subscribe(move |envelope| c.observe(envelope))
};
(collector, sub)
}
#[must_use]
pub fn collect_session(session_id: impl Into<String>) -> (LifecycleCollector, Subscription) {
let collector = LifecycleCollector::new();
let sub = {
let c = collector.clone();
subscribe_session(session_id, move |envelope| c.observe(envelope))
};
(collector, sub)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lifecycle::{emit_for, LifecycleEvent as L};
use crate::progress::{Commit, Outcome};
use std::sync::atomic::{AtomicU64, Ordering};
fn unique_session() -> String {
static N: AtomicU64 = AtomicU64::new(0);
format!("d2a-collector-{}", N.fetch_add(1, Ordering::Relaxed))
}
#[test]
fn real_lifecycle_traffic_reaches_the_progress_contract() {
let s = unique_session();
let (c, _sub) = collect_session(s.clone());
for e in [
L::TurnStarted,
L::Thinking,
L::ToolActivity {
tool: "read_file".into(),
},
L::Thinking,
L::TurnCompleted,
] {
emit_for(Some(s.clone()), e);
}
assert_eq!(
c.committed(),
vec![
Commit::Started {
label: "turn".into()
},
Commit::Finished(Outcome::Completed),
],
"five real events, two committed lines"
);
assert_eq!(c.completed_turns(), 1);
}
#[test]
fn a_long_session_does_not_accumulate() {
let s = unique_session();
let (c, _sub) = collect_session(s.clone());
for _ in 0..1_000 {
emit_for(Some(s.clone()), L::TurnStarted);
emit_for(Some(s.clone()), L::Thinking);
emit_for(Some(s.clone()), L::TurnCompleted);
}
assert_eq!(c.completed_turns(), 1_000, "every turn was seen");
assert_eq!(c.recorded(), 2_000, "and every durable event recorded");
assert_eq!(
c.committed().len(),
2,
"but only the CURRENT turn's story is retained: {:?}",
c.committed()
);
}
#[test]
fn installing_the_collector_does_not_change_what_other_observers_see() {
fn drive(session: &str) -> Vec<L> {
let seen = Arc::new(Mutex::new(Vec::new()));
let sub = {
let seen = Arc::clone(&seen);
subscribe_session(session.to_string(), move |env| {
seen.lock().unwrap().push(env.event.clone());
})
};
for e in [
L::TurnStarted,
L::Thinking,
L::ToolActivity {
tool: "edit_file".into(),
},
L::Blocked,
L::Unblocked,
L::TurnFailed {
reason: Some("boom".into()),
},
] {
emit_for(Some(session.to_string()), e);
}
drop(sub);
let out = seen.lock().unwrap().clone();
out
}
let bare = drive(&unique_session());
let s = unique_session();
let (c, _sub) = collect_session(s.clone());
let dual = drive(&s);
assert_eq!(
bare, dual,
"an existing observer must receive exactly the same stream whether \
or not progress is dual-published"
);
assert_eq!(
c.committed(),
vec![
Commit::Started {
label: "turn".into()
},
Commit::Finished(Outcome::Failed),
],
"…and the collector still saw the turn"
);
}
#[test]
fn the_collector_owns_no_writer() {
let src = include_str!("collector.rs");
for needle in [
concat!("print", "!("),
concat!("eprint", "!("),
concat!("write", "!("),
concat!("writeln", "!("),
concat!("io::", "stdout"),
concat!("io::", "stderr"),
concat!("tty::", "Sink"),
concat!("Line", "Caps"),
concat!("Terminal::", "emit_line"),
concat!(".emit", "("),
] {
assert!(
!src.contains(needle),
"the collector references `{needle}` — it must own no writer at \
all. Silence here is a property of the TYPE, not of which paths \
happen to be reachable (#1866, #1864 constraint 3)."
);
}
}
}