use std::sync::{Arc, Mutex};
use crate::llm::types::TokenUsage;
use super::event::{OnWorkflowEvent, WorkflowEvent};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PhaseProgress {
pub title: String,
pub agents_started: u64,
}
#[derive(Debug, Clone, Default)]
pub struct RunProgress {
pub current_phase: Option<String>,
pub phases: Vec<PhaseProgress>,
pub agents_started: u64,
pub agents_finished: u64,
pub agents_replayed: u64,
pub agents_skipped: u64,
pub agents_failed: u64,
pub log_lines: u64,
pub total_tokens: TokenUsage,
}
impl RunProgress {
pub fn apply(&mut self, event: &WorkflowEvent) {
match event {
WorkflowEvent::PhaseStarted { title } => {
self.current_phase = Some(title.clone());
self.phases.push(PhaseProgress {
title: title.clone(),
agents_started: 0,
});
}
WorkflowEvent::AgentStarted { phase, .. } => {
self.agents_started += 1;
if let Some(phase) = phase
&& let Some(p) = self.phases.iter_mut().rev().find(|p| &p.title == phase)
{
p.agents_started += 1;
}
}
WorkflowEvent::AgentFinished { usage, .. } => {
self.agents_finished += 1;
self.total_tokens += *usage;
}
WorkflowEvent::AgentReplayed { usage, .. } => {
self.agents_replayed += 1;
self.total_tokens += *usage;
}
WorkflowEvent::AgentSkipped { .. } => self.agents_skipped += 1,
WorkflowEvent::AgentFailed { .. } => self.agents_failed += 1,
WorkflowEvent::LogLine { .. } => self.log_lines += 1,
}
}
}
#[derive(Clone, Default)]
pub struct ProgressTracker(Arc<Mutex<RunProgress>>);
impl ProgressTracker {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> RunProgress {
self.0.lock().expect("progress lock poisoned").clone()
}
pub fn callback(&self) -> Arc<OnWorkflowEvent> {
let inner = Arc::clone(&self.0);
Arc::new(move |event: WorkflowEvent| {
if let Ok(mut progress) = inner.lock() {
progress.apply(&event);
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(input: u32, output: u32) -> TokenUsage {
TokenUsage {
input_tokens: input,
output_tokens: output,
..Default::default()
}
}
#[test]
fn phase_started_tracks_current_and_appends() {
let mut p = RunProgress::default();
p.apply(&WorkflowEvent::PhaseStarted {
title: "research".into(),
});
p.apply(&WorkflowEvent::PhaseStarted {
title: "verify".into(),
});
assert_eq!(p.current_phase.as_deref(), Some("verify"));
assert_eq!(p.phases.len(), 2);
assert_eq!(p.phases[0].title, "research");
assert_eq!(p.phases[1].title, "verify");
}
#[test]
fn agent_started_bumps_global_and_matching_phase() {
let mut p = RunProgress::default();
p.apply(&WorkflowEvent::PhaseStarted {
title: "research".into(),
});
p.apply(&WorkflowEvent::AgentStarted {
label: "a".into(),
phase: Some("research".into()),
});
p.apply(&WorkflowEvent::AgentStarted {
label: "b".into(),
phase: Some("research".into()),
});
p.apply(&WorkflowEvent::AgentStarted {
label: "c".into(),
phase: None,
});
assert_eq!(p.agents_started, 3);
assert_eq!(p.phases[0].agents_started, 2);
}
#[test]
fn finished_and_replayed_both_accumulate_tokens_but_count_separately() {
let mut p = RunProgress::default();
p.apply(&WorkflowEvent::AgentFinished {
label: "a".into(),
usage: usage(100, 50),
});
p.apply(&WorkflowEvent::AgentReplayed {
label: "b".into(),
usage: usage(10, 5),
});
assert_eq!(p.agents_finished, 1);
assert_eq!(p.agents_replayed, 1);
assert_eq!(p.total_tokens.input_tokens, 110);
assert_eq!(p.total_tokens.output_tokens, 55);
}
#[test]
fn skipped_failed_and_log_counters() {
let mut p = RunProgress::default();
p.apply(&WorkflowEvent::AgentSkipped { label: "a".into() });
p.apply(&WorkflowEvent::AgentFailed {
label: "b".into(),
error: "boom".into(),
});
p.apply(&WorkflowEvent::AgentFailed {
label: "c".into(),
error: "bang".into(),
});
p.apply(&WorkflowEvent::LogLine { msg: "tick".into() });
assert_eq!(p.agents_skipped, 1);
assert_eq!(p.agents_failed, 2);
assert_eq!(p.log_lines, 1);
}
#[test]
fn callback_folds_events_into_snapshot() {
let tracker = ProgressTracker::new();
let cb = tracker.callback();
cb(WorkflowEvent::PhaseStarted { title: "p1".into() });
cb(WorkflowEvent::AgentStarted {
label: "a".into(),
phase: Some("p1".into()),
});
cb(WorkflowEvent::AgentFinished {
label: "a".into(),
usage: usage(7, 3),
});
let snap = tracker.snapshot();
assert_eq!(snap.current_phase.as_deref(), Some("p1"));
assert_eq!(snap.agents_started, 1);
assert_eq!(snap.agents_finished, 1);
assert_eq!(snap.total_tokens.input_tokens, 7);
}
}