1use std::collections::VecDeque;
22use std::fmt;
23use std::time::Instant;
24
25use tracing::info;
26
27use crate::operations::agent::AgentResult;
28use crate::operations::http::HttpOutput;
29use crate::operations::shell::ShellOutput;
30
31const DEFAULT_MAX_STEPS: usize = 10_000;
34
35pub struct WorkflowTracker {
42 name: String,
43 start: Instant,
44 steps: VecDeque<StepRecord>,
45 max_steps: usize,
46}
47
48struct StepRecord {
49 name: String,
50 kind: StepKind,
51 duration_ms: u64,
52 cost_usd: Option<f64>,
53 input_tokens: Option<u64>,
54 output_tokens: Option<u64>,
55}
56
57enum StepKind {
58 Shell,
59 Http,
60 Agent,
61}
62
63impl fmt::Display for StepKind {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Self::Shell => f.write_str("shell"),
67 Self::Http => f.write_str("http"),
68 Self::Agent => f.write_str("agent"),
69 }
70 }
71}
72
73impl WorkflowTracker {
74 #[must_use = "a tracker does nothing if not used to record steps"]
78 pub fn new(name: &str) -> Self {
79 Self {
80 name: name.to_string(),
81 start: Instant::now(),
82 steps: VecDeque::new(),
83 max_steps: DEFAULT_MAX_STEPS,
84 }
85 }
86
87 pub fn max_steps(mut self, limit: usize) -> Self {
91 self.max_steps = limit;
92 self
93 }
94
95 fn push_step(&mut self, record: StepRecord) {
96 if self.steps.len() >= self.max_steps {
97 self.steps.pop_front();
98 }
99 self.steps.push_back(record);
100 }
101
102 pub fn record_shell(&mut self, name: &str, output: &ShellOutput) {
107 self.push_step(StepRecord {
108 name: name.to_string(),
109 kind: StepKind::Shell,
110 duration_ms: output.duration_ms(),
111 cost_usd: None,
112 input_tokens: None,
113 output_tokens: None,
114 });
115 }
116
117 pub fn record_http(&mut self, name: &str, output: &HttpOutput) {
122 self.push_step(StepRecord {
123 name: name.to_string(),
124 kind: StepKind::Http,
125 duration_ms: output.duration_ms(),
126 cost_usd: None,
127 input_tokens: None,
128 output_tokens: None,
129 });
130 }
131
132 pub fn record_agent(&mut self, name: &str, result: &AgentResult) {
136 self.push_step(StepRecord {
137 name: name.to_string(),
138 kind: StepKind::Agent,
139 duration_ms: result.duration_ms(),
140 cost_usd: result.cost_usd(),
141 input_tokens: result.input_tokens(),
142 output_tokens: result.output_tokens(),
143 });
144 }
145
146 pub fn total_cost_usd(&self) -> f64 {
150 self.steps.iter().filter_map(|s| s.cost_usd).sum()
151 }
152
153 pub fn total_input_tokens(&self) -> u64 {
155 self.steps.iter().filter_map(|s| s.input_tokens).sum()
156 }
157
158 pub fn total_output_tokens(&self) -> u64 {
160 self.steps.iter().filter_map(|s| s.output_tokens).sum()
161 }
162
163 pub fn total_duration_ms(&self) -> u64 {
165 self.start.elapsed().as_millis() as u64
166 }
167
168 pub fn step_count(&self) -> usize {
170 self.steps.len()
171 }
172
173 pub fn summary(&self) {
178 let total_cost = self.total_cost_usd();
179 let total_input = self.total_input_tokens();
180 let total_output = self.total_output_tokens();
181 let total_duration = self.total_duration_ms();
182 let steps = self.step_count();
183
184 info!(
185 workflow = %self.name,
186 steps,
187 total_cost_usd = total_cost,
188 total_input_tokens = total_input,
189 total_output_tokens = total_output,
190 total_duration_ms = total_duration,
191 "workflow completed"
192 );
193
194 for step in &self.steps {
195 info!(
196 workflow = %self.name,
197 step = %step.name,
198 kind = %step.kind,
199 duration_ms = step.duration_ms,
200 cost_usd = step.cost_usd,
201 input_tokens = step.input_tokens,
202 output_tokens = step.output_tokens,
203 "step detail"
204 );
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use serde_json::json;
213
214 use crate::operations::agent::AgentResult;
215 use crate::operations::shell::Shell;
216 use crate::provider::AgentOutput;
217
218 fn make_agent_result(
219 cost: Option<f64>,
220 input_tokens: Option<u64>,
221 output_tokens: Option<u64>,
222 ) -> AgentResult {
223 let mut output = AgentOutput::new(json!("result"));
224 output.cost_usd = cost;
225 output.input_tokens = input_tokens;
226 output.output_tokens = output_tokens;
227 output.duration_ms = 100;
228 AgentResult::from_output(output)
229 }
230
231 async fn make_shell_output() -> ShellOutput {
232 Shell::new("echo test").run().await.unwrap()
233 }
234
235 #[test]
236 fn new_tracker_has_zero_steps_and_zero_cost() {
237 let tracker = WorkflowTracker::new("test");
238 assert_eq!(tracker.step_count(), 0);
239 assert_eq!(tracker.total_cost_usd(), 0.0);
240 }
241
242 #[tokio::test]
243 async fn record_shell_increments_step_count() {
244 let mut tracker = WorkflowTracker::new("test");
245 let output = make_shell_output().await;
246 tracker.record_shell("step1", &output);
247 assert_eq!(tracker.step_count(), 1);
248 }
249
250 #[test]
251 fn record_agent_with_cost_reflected_in_total() {
252 let mut tracker = WorkflowTracker::new("test");
253 let result = make_agent_result(Some(0.05), Some(100), Some(50));
254 tracker.record_agent("agent1", &result);
255 assert_eq!(tracker.total_cost_usd(), 0.05);
256 }
257
258 #[test]
259 fn record_agent_without_cost_does_not_change_total() {
260 let mut tracker = WorkflowTracker::new("test");
261 let result = make_agent_result(None, None, None);
262 tracker.record_agent("agent1", &result);
263 assert_eq!(tracker.total_cost_usd(), 0.0);
264 }
265
266 #[tokio::test]
267 async fn multiple_steps_counted_correctly() {
268 let mut tracker = WorkflowTracker::new("test");
269 let shell = make_shell_output().await;
270 let agent = make_agent_result(Some(0.1), Some(200), Some(100));
271 tracker.record_shell("s1", &shell);
272 tracker.record_agent("a1", &agent);
273 tracker.record_shell("s2", &shell);
274 assert_eq!(tracker.step_count(), 3);
275 }
276
277 #[test]
278 fn total_input_tokens_sums_across_agent_steps() {
279 let mut tracker = WorkflowTracker::new("test");
280 let r1 = make_agent_result(None, Some(100), None);
281 let r2 = make_agent_result(None, Some(250), None);
282 tracker.record_agent("a1", &r1);
283 tracker.record_agent("a2", &r2);
284 assert_eq!(tracker.total_input_tokens(), 350);
285 }
286
287 #[test]
288 fn total_output_tokens_sums_across_agent_steps() {
289 let mut tracker = WorkflowTracker::new("test");
290 let r1 = make_agent_result(None, None, Some(50));
291 let r2 = make_agent_result(None, None, Some(75));
292 tracker.record_agent("a1", &r1);
293 tracker.record_agent("a2", &r2);
294 assert_eq!(tracker.total_output_tokens(), 125);
295 }
296
297 #[test]
298 fn tokens_with_mixed_none_values() {
299 let mut tracker = WorkflowTracker::new("test");
300 let r1 = make_agent_result(None, Some(100), Some(50));
301 let r2 = make_agent_result(None, None, None);
302 let r3 = make_agent_result(None, Some(200), Some(30));
303 tracker.record_agent("a1", &r1);
304 tracker.record_agent("a2", &r2);
305 tracker.record_agent("a3", &r3);
306 assert_eq!(tracker.total_input_tokens(), 300);
307 assert_eq!(tracker.total_output_tokens(), 80);
308 }
309
310 #[test]
311 fn total_duration_ms_is_positive() {
312 let tracker = WorkflowTracker::new("test");
313 assert!(tracker.total_duration_ms() < 1000); }
317
318 #[test]
319 fn summary_does_not_panic_empty() {
320 let tracker = WorkflowTracker::new("empty");
321 tracker.summary();
322 }
323
324 #[tokio::test]
325 async fn summary_does_not_panic_non_empty() {
326 let mut tracker = WorkflowTracker::new("test");
327 let shell = make_shell_output().await;
328 let agent = make_agent_result(Some(0.01), Some(10), Some(5));
329 tracker.record_shell("s1", &shell);
330 tracker.record_agent("a1", &agent);
331 tracker.summary();
332 }
333
334 #[test]
335 fn eviction_when_max_steps_exceeded() {
336 let mut tracker = WorkflowTracker::new("test").max_steps(3);
337 for i in 0..5 {
338 let r = make_agent_result(Some(i as f64), None, None);
339 tracker.record_agent(&format!("step-{i}"), &r);
340 }
341 assert_eq!(tracker.step_count(), 3);
342 assert_eq!(tracker.total_cost_usd(), 2.0 + 3.0 + 4.0);
344 }
345
346 #[test]
347 fn max_steps_one_keeps_last_only() {
348 let mut tracker = WorkflowTracker::new("test").max_steps(1);
349 let r1 = make_agent_result(Some(1.0), Some(100), None);
350 let r2 = make_agent_result(Some(2.0), Some(200), None);
351 tracker.record_agent("a1", &r1);
352 tracker.record_agent("a2", &r2);
353 assert_eq!(tracker.step_count(), 1);
354 assert_eq!(tracker.total_cost_usd(), 2.0);
355 assert_eq!(tracker.total_input_tokens(), 200);
356 }
357
358 #[test]
359 fn max_steps_builder_sets_limit() {
360 let mut tracker = WorkflowTracker::new("test").max_steps(42);
361 for i in 0..50 {
363 let r = make_agent_result(Some(1.0), None, None);
364 tracker.record_agent(&format!("step-{i}"), &r);
365 }
366 assert_eq!(tracker.step_count(), 42);
367 }
368}