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    #[must_use]
40    pub const fn new() -> Self {
41        Self { seq: 1 }
42    }
43
44    /// Create a new `EventLoopLogger` that continues from an existing log file.
45    ///
46    /// This reads the last sequence number from the existing log file
47    /// and starts the counter from `last_seq + 1`. This is important
48    /// for resume scenarios to maintain monotonically increasing sequence
49    /// numbers within a run.
50    ///
51    /// # Arguments
52    ///
53    /// * `workspace` - Workspace implementation for reading the log file
54    /// * `log_path` - Path to the existing event loop log file
55    ///
56    /// # Returns
57    ///
58    /// * `Ok(EventLoopLogger)` - Logger initialized with next sequence number
59    /// * `Err(std::io::Error)` - If reading the log file fails
60    ///
61    /// # Behavior
62    ///
63    /// - If the log file doesn't exist or is empty, starts at seq=1
64    /// - If the log file exists, reads the last line to extract the sequence number
65    /// - If the last line doesn't match the expected format, starts at seq=1
66    /// - The sequence counter is set to `last_seq + 1` to continue the sequence
67    ///
68    /// # Errors
69    ///
70    /// Returns error if the operation fails.
71    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            // Log file doesn't exist, start fresh
77            return Ok(Self { seq: 1 });
78        }
79
80        let content = workspace.read(log_path)?;
81        if content.is_empty() {
82            // Empty file, start fresh
83            return Ok(Self { seq: 1 });
84        }
85
86        // Find the last non-empty line and extract sequence number
87        let last_seq = content
88            .lines()
89            .rev()
90            .find(|line| !line.trim().is_empty())
91            .and_then(|line| {
92                // Log format: "<seq> ts=<rfc3339> phase=<Phase> ..."
93                // Extract the first number (sequence number)
94                line.split_whitespace().next()?.parse::<u64>().ok()
95            })
96            .unwrap_or(0); // If we can't parse, start fresh
97
98        // Next sequence is last_seq + 1
99        Ok(Self { seq: last_seq + 1 })
100    }
101
102    /// Log an effect execution.
103    ///
104    /// This writes a single line to the event loop log with the following format:
105    /// ```text
106    /// <seq> ts=<rfc3339> phase=<Phase> effect=<Effect> event=<Event> [extra=[E1,E2]] [ctx=k1=v1,k2=v2] ms=<N>
107    /// ```
108    ///
109    /// Example:
110    /// ```text
111    /// 1 ts=2026-02-06T14:03:27.123Z phase=Development effect=InvokePrompt event=PromptCompleted ms=1234
112    /// 2 ts=2026-02-06T14:03:28.456Z phase=Development effect=WriteFile event=FileWritten ctx=file=PLAN.md ms=12
113    /// ```
114    ///
115    /// # Best-Effort Logging
116    ///
117    /// Write failures are returned but do not affect pipeline correctness.
118    /// This is intentional: event loop logging is observability-only and must not
119    /// affect pipeline correctness. If logging fails (e.g., disk full, permissions),
120    /// the pipeline continues execution.
121    ///
122    /// Callers who want visibility into logging failures should check the return value
123    /// and log to the pipeline logger if desired.
124    ///
125    /// # Errors
126    ///
127    /// Returns error if the operation fails.
128    pub fn log_effect(&mut self, params: &LogEffectParams<'_>) -> Result<(), std::io::Error> {
129        let ts = Utc::now().to_rfc3339();
130
131        // Format extra events (if any)
132        let extra = if params.extra_events.is_empty() {
133            String::new()
134        } else {
135            format!(" extra=[{}]", params.extra_events.join(","))
136        };
137
138        // Format context (if any)
139        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        // Log a few effects
191        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        // Verify log file exists
218        assert!(workspace.exists(log_path));
219
220        // Verify content
221        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        // Log several effects
245        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        // Verify sequence numbers
261        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        // Should not contain "ctx=" when context is empty
322        assert!(!content.contains("ctx="));
323        // Should not contain "extra=" when no extra events
324        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        // Write some initial log entries
335        {
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        // Create a new logger from the existing log
354        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
355
356        // The next log entry should have seq=4
357        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        // Should contain sequence 1-3 from initial writes
372        assert!(content.contains("1 ts="));
373        assert!(content.contains("2 ts="));
374        assert!(content.contains("3 ts="));
375        // Should contain sequence 4 from the resumed logger
376        assert!(content.contains("4 ts="));
377        // Should NOT contain another sequence 1
378        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        // Create a logger from a nonexistent log file
390        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
391
392        // Should start at seq=1
393        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        // Create an empty log file
418        workspace.write(log_path, "").unwrap();
419
420        // Create a logger from an empty log file
421        let mut logger = EventLoopLogger::from_existing_log(&workspace, log_path).unwrap();
422
423        // Should start at seq=1
424        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}