Skip to main content

ralph_workflow/logging/
event_loop_logger.rs

1use crate::reducer::event::PipelinePhase;
2use crate::workspace::Workspace;
3use chrono::Utc;
4use std::path::Path;
5
6/// Parameters for logging an effect execution.
7pub 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
18/// Logger for recording event loop execution.
19///
20/// This logger writes a human-readable log of the event loop's progression:
21/// - which effects ran
22/// - what events were emitted
23/// - how long each effect took
24/// - what phase/iteration/retry context was active
25///
26/// The log is always-on (not just for crashes) and is written to
27/// `.agent/logs-<run_id>/event_loop.log` for easy diagnosis.
28///
29/// **Redaction:** This logger must never include sensitive content like
30/// prompts, agent outputs, secrets, or credentials.
31pub struct EventLoopLogger {
32    seq: u64,
33}
34
35impl EventLoopLogger {
36    /// Create a new EventLoopLogger.
37    ///
38    /// The sequence counter starts at 1 for the first logged effect.
39    pub fn new() -> Self {
40        Self { seq: 1 }
41    }
42
43    /// Create a new EventLoopLogger that continues from an existing log file.
44    ///
45    /// This reads the last sequence number from the existing log file
46    /// and starts the counter from `last_seq + 1`. This is important
47    /// for resume scenarios to maintain monotonically increasing sequence
48    /// numbers within a run.
49    ///
50    /// # Arguments
51    ///
52    /// * `workspace` - Workspace implementation for reading the log file
53    /// * `log_path` - Path to the existing event loop log file
54    ///
55    /// # Returns
56    ///
57    /// * `Ok(EventLoopLogger)` - Logger initialized with next sequence number
58    /// * `Err(std::io::Error)` - If reading the log file fails
59    ///
60    /// # Behavior
61    ///
62    /// - If the log file doesn't exist or is empty, starts at seq=1
63    /// - If the log file exists, reads the last line to extract the sequence number
64    /// - If the last line doesn't match the expected format, starts at seq=1
65    /// - The sequence counter is set to `last_seq + 1` to continue the sequence
66    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            // Log file doesn't exist, start fresh
72            return Ok(Self { seq: 1 });
73        }
74
75        let content = workspace.read(log_path)?;
76        if content.is_empty() {
77            // Empty file, start fresh
78            return Ok(Self { seq: 1 });
79        }
80
81        // Find the last non-empty line and extract sequence number
82        let last_seq = content
83            .lines()
84            .rev()
85            .find(|line| !line.trim().is_empty())
86            .and_then(|line| {
87                // Log format: "<seq> ts=<rfc3339> phase=<Phase> ..."
88                // Extract the first number (sequence number)
89                line.split_whitespace().next()?.parse::<u64>().ok()
90            })
91            .unwrap_or(0); // If we can't parse, start fresh
92
93        // Next sequence is last_seq + 1
94        Ok(Self { seq: last_seq + 1 })
95    }
96
97    /// Log an effect execution.
98    ///
99    /// This writes a single line to the event loop log with the following format:
100    /// ```text
101    /// <seq> ts=<rfc3339> phase=<Phase> effect=<Effect> event=<Event> [extra=[E1,E2]] [ctx=k1=v1,k2=v2] ms=<N>
102    /// ```
103    ///
104    /// Example:
105    /// ```text
106    /// 1 ts=2026-02-06T14:03:27.123Z phase=Development effect=InvokePrompt event=PromptCompleted ms=1234
107    /// 2 ts=2026-02-06T14:03:28.456Z phase=Development effect=WriteFile event=FileWritten ctx=file=PLAN.md ms=12
108    /// ```
109    ///
110    /// # Best-Effort Logging
111    ///
112    /// Write failures are returned but do not affect pipeline correctness.
113    /// This is intentional: event loop logging is observability-only and must not
114    /// affect pipeline correctness. If logging fails (e.g., disk full, permissions),
115    /// the pipeline continues execution.
116    ///
117    /// Callers who want visibility into logging failures should check the return value
118    /// and log to the pipeline logger if desired.
119    pub fn log_effect(&mut self, params: LogEffectParams) -> Result<(), std::io::Error> {
120        let ts = Utc::now().to_rfc3339();
121
122        // Format extra events (if any)
123        let extra = if params.extra_events.is_empty() {
124            String::new()
125        } else {
126            format!(" extra=[{}]", params.extra_events.join(","))
127        };
128
129        // Format context (if any)
130        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        // Log a few effects
182        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        // Verify log file exists
209        assert!(workspace.exists(log_path));
210
211        // Verify content
212        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        // Log several effects
236        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        // Verify sequence numbers
252        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        // Should not contain "ctx=" when context is empty
314        assert!(!content.contains("ctx="));
315        // Should not contain "extra=" when no extra events
316        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        // Write some initial log entries
327        {
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        // Create a new logger from the existing log
346        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
347
348        // The next log entry should have seq=4
349        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        // Should contain sequence 1-3 from initial writes
364        assert!(content.contains("1 ts="));
365        assert!(content.contains("2 ts="));
366        assert!(content.contains("3 ts="));
367        // Should contain sequence 4 from the resumed logger
368        assert!(content.contains("4 ts="));
369        // Should NOT contain another sequence 1
370        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        // Create a logger from a nonexistent log file
382        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
383
384        // Should start at seq=1
385        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        // Create an empty log file
410        workspace.write(log_path, "").unwrap();
411
412        // Create a logger from an empty log file
413        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
414
415        // Should start at seq=1
416        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}