Source code for ralph.phases.commit_logging

"""Commit logging session for tracking commit generation attempts.

Ported from ralph-workflow/src/phases/commit_logging/io.rs.

This module provides detailed logging for each commit generation attempt,
creating a clear audit trail for debugging parsing failures.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from ralph.phases.commit_attempt_log import CommitAttemptLog

if TYPE_CHECKING:
    from collections.abc import Callable

# Maximum agent name length for filename sanitization
MAX_AGENT_NAME_LENGTH = 20


[docs] @dataclass class CommitLoggingSession: """Session tracker for commit generation logging.""" run_dir: Path attempt_counter: int = 0 is_noop: bool = False
[docs] @classmethod def new( cls, base_log_dir: str, workspace_exists_func: Callable[[Path], bool], workspace_makedirs_func: Callable[[Path], None], ) -> CommitLoggingSession: """Create a new logging session. Creates a unique run directory under the base log path. Args: base_log_dir: Base directory for logs. workspace_exists_func: Function to check if path exists (workspace.exists). workspace_makedirs_func: Function to create directories (workspace.create_dir_all). Returns: A new CommitLoggingSession instance. """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") run_dir = Path(base_log_dir) / f"run_{timestamp}" if not workspace_exists_func(run_dir): workspace_makedirs_func(run_dir) return cls(run_dir=run_dir, attempt_counter=0, is_noop=False)
[docs] @classmethod def noop(cls) -> CommitLoggingSession: """Create a no-op logging session that discards all writes. Returns: A no-op CommitLoggingSession instance. """ return cls( run_dir=Path("/dev/null/ralph-noop-session"), attempt_counter=0, is_noop=True, )
[docs] def next_attempt_number(self) -> int: """Get the next attempt number and increment the counter. Returns: The next attempt number. """ self.attempt_counter = self.attempt_counter + 1 return self.attempt_counter
[docs] def new_attempt(self, agent: str, strategy: str) -> CommitAttemptLog: """Create a new attempt log. Args: agent: Agent name. strategy: Retry strategy. Returns: A new CommitAttemptLog instance. """ attempt_number = self.next_attempt_number() return CommitAttemptLog( attempt_number=attempt_number, agent=agent, strategy=strategy, )
[docs] def write_summary( self, total_attempts: int, final_outcome: str, workspace_write_func: Callable[[str, str], None], ) -> None: """Write summary file at end of session. For no-op sessions, this silently succeeds without writing anything. Args: total_attempts: Total number of attempts. final_outcome: Final outcome string. workspace_write_func: Function to write to workspace (workspace.write). """ if self.is_noop: return summary_path = self.run_dir / "SUMMARY.txt" content = ( f"COMMIT GENERATION SESSION SUMMARY\n" f"================================\n" f"\n" f"Run directory: {self.run_dir}\n" f"Total attempts: {total_attempts}\n" f"Final outcome: {final_outcome}\n" f"\n" f"Individual attempt logs are in this directory.\n" ) workspace_write_func(str(summary_path), content)
[docs] def write_attempt_log( self, attempt_log: CommitAttemptLog, workspace_write_func: Callable[[str, str], None], ) -> None: """Write an attempt log to a file. Args: attempt_log: The attempt log to write. workspace_write_func: Function to write to workspace (workspace.write). """ if self.is_noop: return sanitized_agent = sanitize_agent_name(attempt_log.agent) filename = ( f"attempt_{attempt_log.attempt_number:03d}_" f"{sanitized_agent}_" f"{attempt_log.strategy.replace(' ', '_')}_" f"{attempt_log.timestamp.strftime('%Y%m%dT%H%M%S')}.log" ) log_path = self.run_dir / filename content = self._format_attempt_log(attempt_log) workspace_write_func(str(log_path), content)
def _format_attempt_log(self, log: CommitAttemptLog) -> str: """Format an attempt log as a string. Args: log: The attempt log to format. Returns: Formatted string representation. """ lines: list[str] = [ "=" * 70, "COMMIT GENERATION ATTEMPT LOG", "=" * 70, "", f"Attempt: #{log.attempt_number}", f"Agent: {log.agent}", f"Strategy: {log.strategy}", f"Timestamp: {log.timestamp.strftime('%Y-%m-%d %H:%M:%S')}", "", "-" * 70, "CONTEXT", "-" * 70, "", f"Prompt size: {log.prompt_size_bytes} bytes ({log.prompt_size_bytes // 1024} KB)", f"Diff size: {log.diff_size_bytes} bytes ({log.diff_size_bytes // 1024} KB)", f"Diff truncated: {'YES' if log.diff_was_truncated else 'NO'}", "", ] output_section = log.raw_output if log.raw_output else "[No output captured]" lines.extend( [ "-" * 70, "RAW AGENT OUTPUT", "-" * 70, "", output_section, "", ] ) if log.outcome: lines.extend( [ "-" * 70, "OUTCOME", "-" * 70, "", log.outcome, "", ] ) lines.append("=" * 70) return "\n".join(lines)
def sanitize_agent_name(agent: str) -> str: """Sanitize agent name for use in filename. Args: agent: Original agent name. Returns: Sanitized agent name safe for use in filenames. """ sanitized = "".join(c if c.isalnum() else "_" for c in agent) return sanitized[:MAX_AGENT_NAME_LENGTH] __all__ = [ "CommitAttemptLog", "CommitLoggingSession", ]