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