1use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use serde::Deserialize;
15
16use crate::adapters::adapter_for;
17use crate::core::{ConditionsRecord, Harness, RunRecord, ToolInvocation};
18use crate::pipeline::error::PipelineError;
19use crate::pipeline::io::write_json;
20use crate::pipeline::slots::{run_key, run_slots};
21use crate::validation::{SchemaName, validate_against_schema};
22
23#[derive(Debug, Default, Clone, PartialEq, Eq)]
25pub struct FillTranscriptsResult {
26 pub filled: usize,
27 pub skipped: usize,
28 pub missing: usize,
29}
30
31#[derive(Debug, Deserialize)]
33struct DispatchEnvelope {
34 tasks: Option<Vec<DispatchRef>>,
35}
36
37#[derive(Debug, Deserialize)]
38struct DispatchRef {
39 eval_id: String,
40 condition: String,
41 #[serde(default)]
42 run_index: Option<u32>,
43 #[serde(default)]
44 outputs_dir: Option<String>,
45}
46
47pub fn fill_transcripts(
50 iteration_dir: &Path,
51 harness: Harness,
52 overwrite: bool,
53) -> Result<FillTranscriptsResult, PipelineError> {
54 let conditions_path = iteration_dir.join("conditions.json");
55 if !conditions_path.exists() {
56 return Err(PipelineError::Message(format!(
57 "missing: {}",
58 conditions_path.display()
59 )));
60 }
61 let conditions: ConditionsRecord =
62 serde_json::from_str(&fs::read_to_string(&conditions_path)?)?;
63 let condition_names: Vec<String> = conditions
64 .conditions
65 .iter()
66 .map(|c| c.name.clone())
67 .collect();
68
69 let outputs_by_key = outputs_dirs_by_key(iteration_dir);
70
71 let mut result = FillTranscriptsResult::default();
72 for entry in fs::read_dir(iteration_dir)? {
73 let entry = entry?;
74 let dir_name = entry.file_name().to_string_lossy().into_owned();
75 let Some(eval_id) = dir_name.strip_prefix("eval-") else {
76 continue;
77 };
78
79 for cond in &condition_names {
80 let cond_dir = iteration_dir.join(&dir_name).join(cond);
81 for slot in run_slots(&cond_dir) {
82 let run_path = slot.dir.join("run.json");
83 if !run_path.exists() {
84 continue;
85 }
86
87 let source = run_path.to_string_lossy();
88 let mut run: RunRecord = validate_against_schema(
89 SchemaName::RunRecord,
90 &serde_json::from_str(&fs::read_to_string(&run_path)?)?,
91 &source,
92 )?;
93
94 if run.conversation.is_some() {
95 result.skipped += 1;
99 continue;
100 }
101
102 if !run.tool_invocations.is_empty() && !overwrite {
103 result.skipped += 1;
104 continue;
105 }
106
107 let outputs_dir = outputs_by_key
108 .get(&run_key(eval_id, cond, slot.run_index))
109 .cloned()
110 .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
111
112 let Some(invocations) = invocations_for_run(harness, Path::new(&outputs_dir))
113 else {
114 result.missing += 1;
115 continue;
116 };
117
118 run.tool_invocations = invocations;
119 write_json(&run_path, &run)?;
120 result.filled += 1;
121 }
122 }
123 }
124
125 Ok(result)
126}
127
128fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
132 let mut out = HashMap::new();
133 if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
134 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
135 {
136 for t in env.tasks.unwrap_or_default() {
137 if let Some(dir) = t.outputs_dir {
138 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
139 }
140 }
141 }
142 out
143}
144
145fn invocations_for_run(harness: Harness, outputs_dir: &Path) -> Option<Vec<ToolInvocation>> {
149 let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
150 if !events_path.exists() {
151 return None;
152 }
153 adapter_for(harness).parse_cli_events(&events_path).ok()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use serde_json::{Value, json};
160 use std::path::PathBuf;
161 use tempfile::TempDir;
162
163 fn write_dispatch(iteration_dir: &Path, tasks: Value) {
164 fs::create_dir_all(iteration_dir).unwrap();
165 fs::write(
166 iteration_dir.join("dispatch.json"),
167 serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
168 )
169 .unwrap();
170 }
171
172 fn jsonl(lines: &[Value]) -> String {
173 let body = lines
174 .iter()
175 .map(|l| l.to_string())
176 .collect::<Vec<_>>()
177 .join("\n");
178 format!("{body}\n")
179 }
180
181 fn write_run_record(path: &Path, tool_invocations: Value) {
182 let record = json!({
183 "eval_id": "crash",
184 "condition": "with_skill",
185 "skill_path": "/skill/SKILL.md",
186 "prompt": "Fix it",
187 "files": [],
188 "final_message": "Done.",
189 "tool_invocations": tool_invocations,
190 "total_tokens": Value::Null,
191 "duration_ms": Value::Null,
192 });
193 fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
194 }
195
196 #[test]
199 fn fills_a_claude_run_record_from_outputs_events() {
200 let root = TempDir::new().unwrap();
201 let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
202 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
203 let outputs_dir = cond_dir.join("outputs");
204 fs::create_dir_all(&outputs_dir).unwrap();
205 let run_path = cond_dir.join("run.json");
206 write_run_record(&run_path, json!([]));
207 fs::write(
208 iteration_dir.join("conditions.json"),
209 json!({
210 "mode": "new-skill",
211 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
212 "timestamp": "2026-06-07T00:00:00.000Z",
213 "harness": "claude-code"
214 })
215 .to_string(),
216 )
217 .unwrap();
218 write_dispatch(
219 &iteration_dir,
220 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
221 );
222 fs::write(
224 outputs_dir.join("claude-events.jsonl"),
225 jsonl(&[
226 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
227 json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
228 json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 10, "usage": {"input_tokens": 1, "output_tokens": 1, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}),
229 ]),
230 )
231 .unwrap();
232
233 let result = fill_transcripts(
234 &iteration_dir,
235 Harness::resolve("claude-code").unwrap(),
236 false,
237 )
238 .unwrap();
239 assert_eq!(result.filled, 1);
240 assert_eq!(result.missing, 0);
241
242 let updated: RunRecord =
243 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
244 assert_eq!(
245 serde_json::to_value(&updated.tool_invocations).unwrap(),
246 json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
247 );
248 }
249
250 #[test]
251 fn fills_a_codex_run_record_from_outputs_events() {
252 let root = TempDir::new().unwrap();
253 let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
254 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
255 let outputs_dir = cond_dir.join("outputs");
256 fs::create_dir_all(&outputs_dir).unwrap();
257 let run_path = cond_dir.join("run.json");
258 write_run_record(&run_path, json!([]));
259 fs::write(
260 iteration_dir.join("conditions.json"),
261 json!({
262 "mode": "new-skill",
263 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
264 "timestamp": "2026-06-07T00:00:00.000Z",
265 "harness": "codex"
266 })
267 .to_string(),
268 )
269 .unwrap();
270 write_dispatch(
271 &iteration_dir,
272 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
273 );
274 fs::write(
275 outputs_dir.join("codex-events.jsonl"),
276 jsonl(&[
277 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
278 ]),
279 )
280 .unwrap();
281
282 let result =
283 fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
284 assert_eq!(result.filled, 1);
285 assert_eq!(result.missing, 0);
286
287 let updated: RunRecord =
288 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
289 assert_eq!(
290 serde_json::to_value(&updated.tool_invocations).unwrap(),
291 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
292 );
293 }
294
295 #[test]
296 fn fills_codex_run_records_in_nested_run_dirs() {
297 let root = TempDir::new().unwrap();
298 let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
299 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
300 fs::create_dir_all(&iteration_dir).unwrap();
301 fs::write(
302 iteration_dir.join("conditions.json"),
303 json!({
304 "mode": "new-skill",
305 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
306 "timestamp": "2026-06-07T00:00:00.000Z",
307 "harness": "codex"
308 })
309 .to_string(),
310 )
311 .unwrap();
312 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
313 let run_dir = cond_dir.join(format!("run-{k}"));
314 let outputs_dir = run_dir.join("outputs");
315 fs::create_dir_all(&outputs_dir).unwrap();
316 write_run_record(&run_dir.join("run.json"), json!([]));
317 fs::write(
318 outputs_dir.join("codex-events.jsonl"),
319 jsonl(&[
320 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
321 ]),
322 )
323 .unwrap();
324 }
325
326 let result =
327 fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
328 assert_eq!(result.filled, 2);
329 assert_eq!(result.missing, 0);
330
331 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
332 let updated: RunRecord = serde_json::from_str(
333 &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
334 )
335 .unwrap();
336 assert_eq!(
337 serde_json::to_value(&updated.tool_invocations).unwrap(),
338 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
339 "wrong invocations for run-{k}"
340 );
341 }
342 }
343}