ralph_workflow/phases/commit_logging/message_generation/
attempt_log.rs1#[derive(Debug, Clone)]
6pub struct CommitAttemptLog {
7 pub attempt_number: usize,
9 pub agent: String,
11 pub strategy: String,
13 pub timestamp: DateTime<Local>,
15 pub prompt_size_bytes: usize,
17 pub diff_size_bytes: usize,
19 pub diff_was_truncated: bool,
21 pub raw_output: Option<String>,
23 pub extraction_attempts: Vec<ExtractionAttempt>,
25 pub validation_checks: Vec<ValidationCheck>,
27 pub outcome: Option<AttemptOutcome>,
29}
30
31impl CommitAttemptLog {
32 #[must_use]
34 pub fn new(attempt_number: usize, agent: &str, strategy: &str) -> Self {
35 Self {
36 attempt_number,
37 agent: agent.to_string(),
38 strategy: strategy.to_string(),
39 timestamp: Local::now(),
40 prompt_size_bytes: 0,
41 diff_size_bytes: 0,
42 diff_was_truncated: false,
43 raw_output: None,
44 extraction_attempts: Vec::new(),
45 validation_checks: Vec::new(),
46 outcome: None,
47 }
48 }
49
50 #[must_use]
55 pub fn with_basics(
56 attempt_number: usize,
57 agent: &str,
58 strategy: &str,
59 prompt_size: usize,
60 diff_size: usize,
61 diff_was_truncated: bool,
62 ) -> Self {
63 Self {
64 attempt_number,
65 agent: agent.to_string(),
66 strategy: strategy.to_string(),
67 timestamp: Local::now(),
68 prompt_size_bytes: prompt_size,
69 diff_size_bytes: diff_size,
70 diff_was_truncated,
71 raw_output: None,
72 extraction_attempts: Vec::new(),
73 validation_checks: Vec::new(),
74 outcome: None,
75 }
76 }
77
78 #[must_use]
80 pub fn with_prompt_size(mut self, size: usize) -> Self {
81 self.prompt_size_bytes = size;
82 self
83 }
84
85 #[must_use]
87 pub fn with_diff_info(mut self, size: usize, was_truncated: bool) -> Self {
88 self.diff_size_bytes = size;
89 self.diff_was_truncated = was_truncated;
90 self
91 }
92
93 #[must_use]
97 pub fn with_raw_output(mut self, output: &str) -> Self {
98 const MAX_OUTPUT_SIZE: usize = 50_000;
99 self.raw_output = if output.len() > MAX_OUTPUT_SIZE {
100 Some(format!(
101 "{}\n\n[... truncated {} bytes ...]\n\n{}",
102 &output[..MAX_OUTPUT_SIZE / 2],
103 output.len() - MAX_OUTPUT_SIZE,
104 &output[output.len() - MAX_OUTPUT_SIZE / 2..]
105 ))
106 } else {
107 Some(output.to_string())
108 };
109 self
110 }
111
112 #[must_use]
114 pub fn add_extraction_attempt(mut self, attempt: ExtractionAttempt) -> Self {
115 self.extraction_attempts = self
116 .extraction_attempts
117 .into_iter()
118 .chain([attempt])
119 .collect();
120 self
121 }
122
123 #[cfg(test)]
125 #[must_use]
126 pub fn with_validation_checks(mut self, checks: Vec<ValidationCheck>) -> Self {
127 self.validation_checks = checks;
128 self
129 }
130
131 #[must_use]
133 pub fn with_outcome(mut self, outcome: AttemptOutcome) -> Self {
134 self.outcome = Some(outcome);
135 self
136 }
137
138 pub fn write_to_workspace(
156 &self,
157 log_dir: &Path,
158 workspace: &dyn Workspace,
159 ) -> std::io::Result<PathBuf> {
160 workspace.create_dir_all(log_dir)?;
162
163 let filename = format!(
165 "attempt_{:03}_{}_{}_{}.log",
166 self.attempt_number,
167 sanitize_agent_name(&self.agent),
168 self.strategy.replace(' ', "_"),
169 self.timestamp.format("%Y%m%dT%H%M%S")
170 );
171 let log_path = log_dir.join(filename);
172
173 let content: String = [
175 self.header_as_string(),
176 self.context_as_string(),
177 self.raw_output_as_string(),
178 self.extraction_attempts_as_string(),
179 self.validation_as_string(),
180 self.outcome_as_string(),
181 ]
182 .into_iter()
183 .collect();
184
185 workspace.write(&log_path, &content)?;
187 Ok(log_path)
188 }
189
190 fn header_as_string(&self) -> String {
191 format!(
192 "========================================================================\n\
193 COMMIT GENERATION ATTEMPT LOG\n\
194 ========================================================================\n\
195 \n\
196 Attempt: #{}\n\
197 Agent: {}\n\
198 Strategy: {}\n\
199 Timestamp: {}\n\
200 \n",
201 self.attempt_number,
202 self.agent,
203 self.strategy,
204 self.timestamp.format("%Y-%m-%d %H:%M:%S %Z")
205 )
206 }
207
208 fn context_as_string(&self) -> String {
209 format!(
210 "------------------------------------------------------------------------\n\
211 CONTEXT\n\
212 ---------------------------------------------------------------------------\n\
213 \n\
214 Prompt size: {} bytes ({} KB)\n\
215 Diff size: {} bytes ({} KB)\n\
216 Diff truncated: {}\n\
217 \n",
218 self.prompt_size_bytes,
219 self.prompt_size_bytes / 1024,
220 self.diff_size_bytes,
221 self.diff_size_bytes / 1024,
222 if self.diff_was_truncated { "YES" } else { "NO" }
223 )
224 }
225
226 fn raw_output_as_string(&self) -> String {
227 let output_section = match &self.raw_output {
228 Some(output) => output.as_str(),
229 None => "[No output captured]",
230 };
231 format!(
232 "------------------------------------------------------------------------\n\
233 RAW AGENT OUTPUT\n\
234 ---------------------------------------------------------------------------\n\
235 \n\
236 {output_section}\n\
237 \n"
238 )
239 }
240
241 fn extraction_attempts_as_string(&self) -> String {
242 let attempts_section = if self.extraction_attempts.is_empty() {
243 "[No extraction attempts recorded]".to_string()
244 } else {
245 self.extraction_attempts
246 .iter()
247 .enumerate()
248 .map(|(i, attempt)| {
249 let status = if attempt.success {
250 "✓ SUCCESS"
251 } else {
252 "✗ FAILED"
253 };
254 format!(
255 "{}. {} [{}]\n Detail: {}\n",
256 i + 1,
257 attempt.method,
258 status,
259 attempt.detail
260 )
261 })
262 .collect::<Vec<_>>()
263 .join("")
264 };
265 format!(
266 "------------------------------------------------------------------------\n\
267 EXTRACTION ATTEMPTS\n\
268 ---------------------------------------------------------------------------\n\
269 \n\
270 {attempts_section}\n\
271 \n"
272 )
273 }
274
275 fn validation_as_string(&self) -> String {
276 let validation_section = if self.validation_checks.is_empty() {
277 "[No validation checks recorded]".to_string()
278 } else {
279 self.validation_checks
280 .iter()
281 .map(|check| {
282 let status = if check.passed { "✓ PASS" } else { "✗ FAIL" };
283 if let Some(error) = &check.error {
284 format!(" [{status}] {}: {error}", check.name)
285 } else {
286 format!(" [{status}] {}", check.name)
287 }
288 })
289 .collect::<Vec<_>>()
290 .join("\n")
291 };
292 format!(
293 "------------------------------------------------------------------------\n\
294 VALIDATION RESULTS\n\
295 ---------------------------------------------------------------------------\n\
296 \n\
297 {validation_section}\n\
298 \n"
299 )
300 }
301
302 fn outcome_as_string(&self) -> String {
303 let outcome_section = match &self.outcome {
304 Some(outcome) => outcome.to_string(),
305 None => "[Outcome not recorded]".to_string(),
306 };
307 format!(
308 "------------------------------------------------------------------------\n\
309 OUTCOME\n\
310 ---------------------------------------------------------------------------\n\
311 \n\
312 {outcome_section}\n\
313 \n\
314 ========================================================================\n"
315 )
316 }
317}
318
319fn sanitize_agent_name(agent: &str) -> String {
321 agent
322 .chars()
323 .map(|c| if c.is_alphanumeric() { c } else { '_' })
324 .collect::<String>()
325 .chars()
326 .take(MAX_AGENT_NAME_LENGTH)
327 .collect()
328}