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.tool_invocations.is_empty() && !overwrite {
95 result.skipped += 1;
96 continue;
97 }
98
99 let outputs_dir = outputs_by_key
100 .get(&run_key(eval_id, cond, slot.run_index))
101 .cloned()
102 .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
103
104 let Some(invocations) = invocations_for_run(harness, Path::new(&outputs_dir))
105 else {
106 result.missing += 1;
107 continue;
108 };
109
110 run.tool_invocations = invocations;
111 write_json(&run_path, &run)?;
112 result.filled += 1;
113 }
114 }
115 }
116
117 Ok(result)
118}
119
120fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
124 let mut out = HashMap::new();
125 if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
126 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
127 {
128 for t in env.tasks.unwrap_or_default() {
129 if let Some(dir) = t.outputs_dir {
130 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
131 }
132 }
133 }
134 out
135}
136
137fn invocations_for_run(harness: Harness, outputs_dir: &Path) -> Option<Vec<ToolInvocation>> {
141 let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
142 if !events_path.exists() {
143 return None;
144 }
145 adapter_for(harness).parse_cli_events(&events_path).ok()
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use serde_json::{Value, json};
152 use std::path::PathBuf;
153 use tempfile::TempDir;
154
155 fn write_dispatch(iteration_dir: &Path, tasks: Value) {
156 fs::create_dir_all(iteration_dir).unwrap();
157 fs::write(
158 iteration_dir.join("dispatch.json"),
159 serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
160 )
161 .unwrap();
162 }
163
164 fn jsonl(lines: &[Value]) -> String {
165 let body = lines
166 .iter()
167 .map(|l| l.to_string())
168 .collect::<Vec<_>>()
169 .join("\n");
170 format!("{body}\n")
171 }
172
173 fn write_run_record(path: &Path, tool_invocations: Value) {
174 let record = json!({
175 "eval_id": "crash",
176 "condition": "with_skill",
177 "skill_path": "/skill/SKILL.md",
178 "prompt": "Fix it",
179 "files": [],
180 "final_message": "Done.",
181 "tool_invocations": tool_invocations,
182 "total_tokens": Value::Null,
183 "duration_ms": Value::Null,
184 });
185 fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
186 }
187
188 #[test]
191 fn fills_a_claude_run_record_from_outputs_events() {
192 let root = TempDir::new().unwrap();
193 let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
194 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
195 let outputs_dir = cond_dir.join("outputs");
196 fs::create_dir_all(&outputs_dir).unwrap();
197 let run_path = cond_dir.join("run.json");
198 write_run_record(&run_path, json!([]));
199 fs::write(
200 iteration_dir.join("conditions.json"),
201 json!({
202 "mode": "new-skill",
203 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
204 "timestamp": "2026-06-07T00:00:00.000Z",
205 "harness": "claude-code"
206 })
207 .to_string(),
208 )
209 .unwrap();
210 write_dispatch(
211 &iteration_dir,
212 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
213 );
214 fs::write(
216 outputs_dir.join("claude-events.jsonl"),
217 jsonl(&[
218 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
219 json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
220 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}}),
221 ]),
222 )
223 .unwrap();
224
225 let result = fill_transcripts(
226 &iteration_dir,
227 Harness::resolve("claude-code").unwrap(),
228 false,
229 )
230 .unwrap();
231 assert_eq!(result.filled, 1);
232 assert_eq!(result.missing, 0);
233
234 let updated: RunRecord =
235 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
236 assert_eq!(
237 serde_json::to_value(&updated.tool_invocations).unwrap(),
238 json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
239 );
240 }
241
242 #[test]
243 fn fills_a_codex_run_record_from_outputs_events() {
244 let root = TempDir::new().unwrap();
245 let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
246 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
247 let outputs_dir = cond_dir.join("outputs");
248 fs::create_dir_all(&outputs_dir).unwrap();
249 let run_path = cond_dir.join("run.json");
250 write_run_record(&run_path, json!([]));
251 fs::write(
252 iteration_dir.join("conditions.json"),
253 json!({
254 "mode": "new-skill",
255 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
256 "timestamp": "2026-06-07T00:00:00.000Z",
257 "harness": "codex"
258 })
259 .to_string(),
260 )
261 .unwrap();
262 write_dispatch(
263 &iteration_dir,
264 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
265 );
266 fs::write(
267 outputs_dir.join("codex-events.jsonl"),
268 jsonl(&[
269 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
270 ]),
271 )
272 .unwrap();
273
274 let result =
275 fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
276 assert_eq!(result.filled, 1);
277 assert_eq!(result.missing, 0);
278
279 let updated: RunRecord =
280 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
281 assert_eq!(
282 serde_json::to_value(&updated.tool_invocations).unwrap(),
283 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
284 );
285 }
286
287 #[test]
288 fn fills_codex_run_records_in_nested_run_dirs() {
289 let root = TempDir::new().unwrap();
290 let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
291 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
292 fs::create_dir_all(&iteration_dir).unwrap();
293 fs::write(
294 iteration_dir.join("conditions.json"),
295 json!({
296 "mode": "new-skill",
297 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
298 "timestamp": "2026-06-07T00:00:00.000Z",
299 "harness": "codex"
300 })
301 .to_string(),
302 )
303 .unwrap();
304 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
305 let run_dir = cond_dir.join(format!("run-{k}"));
306 let outputs_dir = run_dir.join("outputs");
307 fs::create_dir_all(&outputs_dir).unwrap();
308 write_run_record(&run_dir.join("run.json"), json!([]));
309 fs::write(
310 outputs_dir.join("codex-events.jsonl"),
311 jsonl(&[
312 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
313 ]),
314 )
315 .unwrap();
316 }
317
318 let result =
319 fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
320 assert_eq!(result.filled, 2);
321 assert_eq!(result.missing, 0);
322
323 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
324 let updated: RunRecord = serde_json::from_str(
325 &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
326 )
327 .unwrap();
328 assert_eq!(
329 serde_json::to_value(&updated.tool_invocations).unwrap(),
330 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
331 "wrong invocations for run-{k}"
332 );
333 }
334 }
335}