1use crate::reducer::event::PipelinePhase;
2use crate::workspace::Workspace;
3use chrono::Utc;
4use std::path::Path;
5
6pub struct LogEffectParams<'a> {
8 pub workspace: &'a dyn Workspace,
9 pub log_path: &'a Path,
10 pub phase: PipelinePhase,
11 pub effect: &'a str,
12 pub primary_event: &'a str,
13 pub extra_events: &'a [String],
14 pub duration_ms: u64,
15 pub context: &'a [(&'a str, &'a str)],
16}
17
18pub struct EventLoopLogger {
32 seq: u64,
33}
34
35impl EventLoopLogger {
36 #[must_use]
40 pub const fn new() -> Self {
41 Self { seq: 1 }
42 }
43
44 pub fn from_existing_log(
72 workspace: &dyn crate::workspace::Workspace,
73 log_path: &Path,
74 ) -> Result<Self, std::io::Error> {
75 if !workspace.exists(log_path) {
76 return Ok(Self { seq: 1 });
78 }
79
80 let content = workspace.read(log_path)?;
81 if content.is_empty() {
82 return Ok(Self { seq: 1 });
84 }
85
86 let last_seq = content
88 .lines()
89 .rev()
90 .find(|line| !line.trim().is_empty())
91 .and_then(|line| {
92 line.split_whitespace().next()?.parse::<u64>().ok()
95 })
96 .unwrap_or(0); Ok(Self { seq: last_seq + 1 })
100 }
101
102 pub fn log_effect(&mut self, params: &LogEffectParams<'_>) -> Result<(), std::io::Error> {
129 let ts = Utc::now().to_rfc3339();
130
131 let extra = if params.extra_events.is_empty() {
133 String::new()
134 } else {
135 format!(" extra=[{}]", params.extra_events.join(","))
136 };
137
138 let ctx = if params.context.is_empty() {
140 String::new()
141 } else {
142 let pairs: Vec<String> = params
143 .context
144 .iter()
145 .map(|(k, v)| format!("{k}={v}"))
146 .collect();
147 format!(" ctx={}", pairs.join(","))
148 };
149
150 let line = format!(
151 "{} ts={} phase={} effect={} event={}{}{} ms={}\n",
152 self.seq,
153 ts,
154 params.phase,
155 params.effect,
156 params.primary_event,
157 extra,
158 ctx,
159 params.duration_ms
160 );
161
162 params
163 .workspace
164 .append_bytes(params.log_path, line.as_bytes())?;
165
166 self.seq += 1;
167 Ok(())
168 }
169}
170
171impl Default for EventLoopLogger {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::workspace::WorkspaceFs;
181
182 #[test]
183 fn test_event_loop_logger_basic() {
184 let tempdir = tempfile::tempdir().unwrap();
185 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
186
187 let log_path = std::path::Path::new("event_loop.log");
188 let mut logger = EventLoopLogger::new();
189
190 logger
192 .log_effect(&LogEffectParams {
193 workspace: &workspace,
194 log_path,
195 phase: PipelinePhase::Development,
196 effect: "InvokePrompt",
197 primary_event: "PromptCompleted",
198 extra_events: &[],
199 duration_ms: 1234,
200 context: &[("iteration", "1")],
201 })
202 .unwrap();
203
204 logger
205 .log_effect(&LogEffectParams {
206 workspace: &workspace,
207 log_path,
208 phase: PipelinePhase::Development,
209 effect: "WriteFile",
210 primary_event: "FileWritten",
211 extra_events: &["CheckpointSaved".to_string()],
212 duration_ms: 12,
213 context: &[],
214 })
215 .unwrap();
216
217 assert!(workspace.exists(log_path));
219
220 let content = workspace.read(log_path).unwrap();
222 assert!(content.contains("1 ts="));
223 assert!(content.contains("phase=Development"));
224 assert!(content.contains("effect=InvokePrompt"));
225 assert!(content.contains("event=PromptCompleted"));
226 assert!(content.contains("ms=1234"));
227 assert!(content.contains("ctx=iteration=1"));
228
229 assert!(content.contains("2 ts="));
230 assert!(content.contains("effect=WriteFile"));
231 assert!(content.contains("event=FileWritten"));
232 assert!(content.contains("extra=[CheckpointSaved]"));
233 assert!(content.contains("ms=12"));
234 }
235
236 #[test]
237 fn test_event_loop_logger_sequence_increment() {
238 let tempdir = tempfile::tempdir().unwrap();
239 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
240
241 let log_path = std::path::Path::new("event_loop.log");
242 let mut logger = EventLoopLogger::new();
243
244 for i in 0..5 {
246 logger
247 .log_effect(&LogEffectParams {
248 workspace: &workspace,
249 log_path,
250 phase: PipelinePhase::Planning,
251 effect: "TestEffect",
252 primary_event: "TestEvent",
253 extra_events: &[],
254 duration_ms: 10 * i,
255 context: &[],
256 })
257 .unwrap();
258 }
259
260 let content = workspace.read(log_path).unwrap();
262 for i in 1..=5 {
263 assert!(
264 content.contains(&format!("{i} ts=")),
265 "Should contain sequence number {i}"
266 );
267 }
268 }
269
270 #[test]
271 fn test_event_loop_logger_context_formatting() {
272 let tempdir = tempfile::tempdir().unwrap();
273 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
274
275 let log_path = std::path::Path::new("event_loop.log");
276 let mut logger = EventLoopLogger::new();
277
278 logger
279 .log_effect(&LogEffectParams {
280 workspace: &workspace,
281 log_path,
282 phase: PipelinePhase::Review,
283 effect: "InvokeReviewer",
284 primary_event: "ReviewCompleted",
285 extra_events: &[],
286 duration_ms: 5000,
287 context: &[
288 ("reviewer_pass", "2"),
289 ("agent_index", "3"),
290 ("retry_cycle", "1"),
291 ],
292 })
293 .unwrap();
294
295 let content = workspace.read(log_path).unwrap();
296 assert!(content.contains("ctx=reviewer_pass=2,agent_index=3,retry_cycle=1"));
297 }
298
299 #[test]
300 fn test_event_loop_logger_empty_context() {
301 let tempdir = tempfile::tempdir().unwrap();
302 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
303
304 let log_path = std::path::Path::new("event_loop.log");
305 let mut logger = EventLoopLogger::new();
306
307 logger
308 .log_effect(&LogEffectParams {
309 workspace: &workspace,
310 log_path,
311 phase: PipelinePhase::CommitMessage,
312 effect: "GenerateCommit",
313 primary_event: "CommitGenerated",
314 extra_events: &[],
315 duration_ms: 100,
316 context: &[],
317 })
318 .unwrap();
319
320 let content = workspace.read(log_path).unwrap();
321 assert!(!content.contains("ctx="));
323 assert!(!content.contains("extra="));
325 }
326
327 #[test]
328 fn test_event_loop_logger_from_existing_log() {
329 let tempdir = tempfile::tempdir().unwrap();
330 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
331
332 let log_path = std::path::Path::new("event_loop.log");
333
334 {
336 let mut logger = EventLoopLogger::new();
337 for i in 0..3 {
338 logger
339 .log_effect(&LogEffectParams {
340 workspace: &workspace,
341 log_path,
342 phase: PipelinePhase::Development,
343 effect: "TestEffect",
344 primary_event: "TestEvent",
345 extra_events: &[],
346 duration_ms: 10 * i,
347 context: &[],
348 })
349 .unwrap();
350 }
351 }
352
353 let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
355
356 logger
358 .log_effect(&LogEffectParams {
359 workspace: &workspace,
360 log_path,
361 phase: PipelinePhase::Review,
362 effect: "ResumeEffect",
363 primary_event: "ResumeEvent",
364 extra_events: &[],
365 duration_ms: 100,
366 context: &[],
367 })
368 .unwrap();
369
370 let content = workspace.read(log_path).unwrap();
371 assert!(content.contains("1 ts="));
373 assert!(content.contains("2 ts="));
374 assert!(content.contains("3 ts="));
375 assert!(content.contains("4 ts="));
377 let seq1_count = content.matches("1 ts=").count();
379 assert_eq!(seq1_count, 1, "Should only have one '1 ts=' entry");
380 }
381
382 #[test]
383 fn test_event_loop_logger_from_nonexistent_log() {
384 let tempdir = tempfile::tempdir().unwrap();
385 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
386
387 let log_path = std::path::Path::new("event_loop.log");
388
389 let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
391
392 logger
394 .log_effect(&LogEffectParams {
395 workspace: &workspace,
396 log_path,
397 phase: PipelinePhase::Development,
398 effect: "TestEffect",
399 primary_event: "TestEvent",
400 extra_events: &[],
401 duration_ms: 10,
402 context: &[],
403 })
404 .unwrap();
405
406 let content = workspace.read(log_path).unwrap();
407 assert!(content.contains("1 ts="));
408 }
409
410 #[test]
411 fn test_event_loop_logger_from_empty_log() {
412 let tempdir = tempfile::tempdir().unwrap();
413 let workspace = WorkspaceFs::new(tempdir.path().to_path_buf());
414
415 let log_path = std::path::Path::new("event_loop.log");
416
417 workspace.write(log_path, "").unwrap();
419
420 let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
422
423 logger
425 .log_effect(&LogEffectParams {
426 workspace: &workspace,
427 log_path,
428 phase: PipelinePhase::Development,
429 effect: "TestEffect",
430 primary_event: "TestEvent",
431 extra_events: &[],
432 duration_ms: 10,
433 context: &[],
434 })
435 .unwrap();
436
437 let content = workspace.read(log_path).unwrap();
438 assert!(content.contains("1 ts="));
439 }
440}