1use crate::events::Event;
10use crate::types::{
11 Feature, FeatureStatus, Milestone, MilestoneStatus, MissionState, Role, RunResult,
12};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct InstructionPair {
21 pub instruction: String,
22 pub response: String,
23 pub model: String,
24 pub quant: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub weight_hash: Option<String>,
27 pub mission_id: String,
28 pub feature_id: String,
29 pub run_id: String,
30}
31
32fn feature_and_milestone_status<'a>(
36 state: &'a MissionState,
37 feature_id: &str,
38) -> Option<(&'a Feature, MilestoneStatus)> {
39 state.mission.milestones.iter().find_map(|m: &Milestone| {
40 m.features
41 .iter()
42 .find(|f| f.id == feature_id)
43 .map(|f| (f, m.status))
44 })
45}
46
47pub fn export_validated_traces(state: &MissionState, _events: &[Event]) -> Vec<InstructionPair> {
62 let mut pairs = Vec::new();
63 for run in state.runs.values() {
66 if run.role != Role::Worker {
67 continue;
68 }
69 let Some(feature_id) = &run.feature_id else {
70 continue;
71 };
72 let Some((feature, milestone_status)) = feature_and_milestone_status(state, feature_id)
73 else {
74 continue;
75 };
76 if feature.status != FeatureStatus::Complete
77 || milestone_status != MilestoneStatus::Complete
78 {
79 continue;
80 }
81 if run.result != Some(RunResult::Pass) {
82 continue;
83 }
84 let Some(report) = &run.report else {
85 continue;
86 };
87
88 let mut instruction = feature.spec.clone();
89 if !feature.validation_criteria.is_empty() {
90 instruction.push_str("\n\nValidation criteria:\n");
91 for criterion in &feature.validation_criteria {
92 instruction.push_str("- ");
93 instruction.push_str(criterion);
94 instruction.push('\n');
95 }
96 }
97
98 let mut response = report.summary.clone();
99 if !report.test_evidence.is_empty() {
100 response.push_str("\n\nTest evidence:\n");
101 response.push_str(&report.test_evidence);
102 }
103 if !report.commits.is_empty() {
104 response.push_str("\n\nCommits:\n");
105 response.push_str(&report.commits.join("\n"));
106 }
107
108 pairs.push(InstructionPair {
109 instruction,
110 response,
111 model: run.model.clone(),
112 quant: run.quant.clone(),
113 weight_hash: run.weight_hash.clone(),
114 mission_id: state.mission.id.clone(),
115 feature_id: feature_id.clone(),
116 run_id: run.id.clone(),
117 });
118 }
119 pairs
120}
121
122pub fn to_jsonl(pairs: &[InstructionPair]) -> String {
127 let mut out = String::new();
128 for pair in pairs {
129 out.push_str(&serde_json::to_string(pair).expect("InstructionPair always serializes"));
130 out.push('\n');
131 }
132 out
133}
134
135pub fn write_export_output(path: &std::path::Path, bytes: &[u8]) -> crate::error::Result<()> {
150 use cap_fs_ext::OpenOptionsFollowExt as _;
151 use cap_primitives::fs::FollowSymlinks;
152 use std::io::Write as _;
153 use std::path::PathBuf;
154
155 let file_name = path.file_name().ok_or_else(|| {
156 crate::error::EngineError::InvalidState(format!(
157 "export output path {} has no file name",
158 path.display()
159 ))
160 })?;
161 let parent = match path.parent() {
164 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
165 _ => PathBuf::from("."),
166 };
167 let anchored = parent.join(file_name);
168 let (dir, name) = crate::paths::open_parent_nofollow(&anchored)?;
169
170 let refusal = || {
171 crate::error::EngineError::InvalidState(format!(
172 "refusing to write export output through a symlink or non-regular file: {}",
173 path.display()
174 ))
175 };
176 match dir.symlink_metadata(&name) {
177 Ok(metadata) if metadata.file_type().is_file() => {}
178 Ok(_) => return Err(refusal()),
179 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
180 Err(error) => return Err(error.into()),
181 }
182
183 let tmp = format!(
184 ".{}.{}.kranz-export.tmp",
185 name.to_string_lossy(),
186 std::process::id()
187 );
188 let write = (|| -> crate::error::Result<()> {
189 let mut options = cap_std::fs::OpenOptions::new();
190 options
191 .write(true)
192 .create_new(true)
193 .follow(FollowSymlinks::No);
194 let mut file = dir.open_with(&tmp, &options)?;
195 file.write_all(bytes)?;
196 file.sync_data()?;
197 drop(file);
198 match dir.rename(&tmp, &dir, &name) {
202 Ok(()) => Ok(()),
203 Err(error) if cfg!(windows) => {
204 match dir.symlink_metadata(&name) {
205 Ok(metadata) if metadata.file_type().is_file() => {
206 dir.remove_file(&name)?;
207 }
208 Ok(_) => return Err(refusal()),
209 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(error.into()),
210 Err(e) => return Err(e.into()),
211 }
212 dir.rename(&tmp, &dir, &name)?;
213 Ok(())
214 }
215 Err(error) => Err(error.into()),
216 }
217 })();
218 if write.is_err() {
219 let _ = dir.remove_file(&tmp);
220 }
221 write
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::reducer::fold;
228 use crate::types::*;
229 use chrono::{DateTime, TimeZone, Utc};
230
231 const MISSION: &str = "m-1";
232
233 fn base_ts() -> DateTime<Utc> {
234 Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
235 }
236
237 fn ev(seq: u64, kind: crate::events::EventKind) -> Event {
238 Event {
239 seq,
240 ts: base_ts() + chrono::Duration::seconds(seq as i64),
241 mission_id: MISSION.to_string(),
242 kind,
243 }
244 }
245
246 fn plan_feature(title: &str) -> PlanFeature {
247 PlanFeature {
248 title: title.to_string(),
249 spec: format!("spec for {title}"),
250 validation_criteria: vec![format!("{title} works")],
251 }
252 }
253
254 fn plan() -> Plan {
256 Plan {
257 goal: "build the thing".to_string(),
258 validation_contract: vec![],
259 milestones: vec![PlanMilestone {
260 title: "milestone one".to_string(),
261 features: vec![plan_feature("alpha"), plan_feature("beta")],
262 }],
263 considered_alternatives: None,
264 command_grants: vec![],
265 touch_set: vec![],
266 standards_manifest: None,
267 reviewer_independence: None,
268 }
269 }
270
271 fn spawn(run_id: &str, feature_id: &str) -> crate::events::EventKind {
272 crate::events::EventKind::WorkerSpawned {
273 backend: None,
274 run_id: run_id.to_string(),
275 role: Role::Worker,
276 feature_id: Some(feature_id.to_string()),
277 milestone_id: None,
278 candidate: None,
279 executor_route: None,
280 sdk_session_id: format!("sess-{run_id}"),
281 model: "sonnet".to_string(),
282 quant: "n/a".to_string(),
283 weight_hash: None,
284 prompt_hash: "deadbeef".to_string(),
285 transcript_path: format!("runs/{run_id}.jsonl"),
286 }
287 }
288
289 fn completed_with_report(
290 run_id: &str,
291 result: RunResult,
292 summary: &str,
293 ) -> crate::events::EventKind {
294 crate::events::EventKind::WorkerCompleted {
295 run_id: run_id.to_string(),
296 result,
297 tokens: TokenUsage::default(),
298 cost_usd: None,
299 report: Some(WorkerReport {
300 result,
301 summary: summary.to_string(),
302 files_touched: vec![],
303 tests_added: vec![],
304 test_evidence: "cargo test: ok".to_string(),
305 dependencies_added: vec![],
306 known_gaps: vec![],
307 commits: vec!["deadbeef commit".to_string()],
308 commands_run: vec![],
309 escalation: None,
310 questions: None,
311 }),
312 }
313 }
314
315 fn fixture_events() -> Vec<Event> {
318 vec![
319 ev(
320 1,
321 crate::events::EventKind::MissionCreated {
322 goal: "build the thing".to_string(),
323 base_branch: "main".to_string(),
324 mission_branch: format!("kranz/mission-{MISSION}"),
325 config: MissionConfig::default(),
326 },
327 ),
328 ev(
329 2,
330 crate::events::EventKind::PlanApproved {
331 plan: plan(),
332 base_sha: None,
333 },
334 ),
335 ev(
336 3,
337 crate::events::EventKind::MilestoneStarted {
338 milestone_id: "ms-1".to_string(),
339 start_sha: "abc123".to_string(),
340 },
341 ),
342 ev(
343 4,
344 crate::events::EventKind::FeatureStarted {
345 feature_id: "f-1-1".to_string(),
346 },
347 ),
348 ev(5, spawn("r-pass", "f-1-1")),
349 ev(
350 6,
351 completed_with_report("r-pass", RunResult::Pass, "did the alpha thing"),
352 ),
353 ev(
354 7,
355 crate::events::EventKind::FeatureCompleted {
356 feature_id: "f-1-1".to_string(),
357 commits: vec!["deadbeef".to_string()],
358 },
359 ),
360 ev(
361 8,
362 crate::events::EventKind::FeatureStarted {
363 feature_id: "f-1-2".to_string(),
364 },
365 ),
366 ev(9, spawn("r-fail", "f-1-2")),
367 ev(
368 10,
369 completed_with_report("r-fail", RunResult::Fail, "could not do the beta thing"),
370 ),
371 ev(
372 11,
373 crate::events::EventKind::FeatureFailed {
374 feature_id: "f-1-2".to_string(),
375 reason: "gave up".to_string(),
376 commits: Vec::new(),
377 },
378 ),
379 ev(
380 12,
381 crate::events::EventKind::MilestoneCompleted {
382 milestone_id: "ms-1".to_string(),
383 tag: None,
384 },
385 ),
386 ]
387 }
388
389 #[test]
390 fn passed_only() {
391 let events = fixture_events();
392 let state = fold(&events).unwrap();
393
394 let pairs = export_validated_traces(&state, &events);
395
396 assert_eq!(
397 pairs.len(),
398 1,
399 "expected exactly one passed trace: {pairs:?}"
400 );
401 assert_eq!(pairs[0].run_id, "r-pass");
402 assert_eq!(pairs[0].feature_id, "f-1-1");
403 assert!(pairs.iter().all(|p| p.run_id != "r-fail"));
404 }
405
406 #[test]
407 fn passed_only_excludes_failed_respawn_attempt() {
408 let events = vec![
412 ev(
413 1,
414 crate::events::EventKind::MissionCreated {
415 goal: "build the thing".to_string(),
416 base_branch: "main".to_string(),
417 mission_branch: format!("kranz/mission-{MISSION}"),
418 config: MissionConfig::default(),
419 },
420 ),
421 ev(
422 2,
423 crate::events::EventKind::PlanApproved {
424 plan: plan(),
425 base_sha: None,
426 },
427 ),
428 ev(
429 3,
430 crate::events::EventKind::MilestoneStarted {
431 milestone_id: "ms-1".to_string(),
432 start_sha: "abc123".to_string(),
433 },
434 ),
435 ev(
436 4,
437 crate::events::EventKind::FeatureStarted {
438 feature_id: "f-1-1".to_string(),
439 },
440 ),
441 ev(5, spawn("r-attempt-1", "f-1-1")),
442 ev(
443 6,
444 completed_with_report("r-attempt-1", RunResult::Fail, "first attempt failed"),
445 ),
446 ev(7, spawn("r-attempt-2", "f-1-1")),
447 ev(
448 8,
449 completed_with_report("r-attempt-2", RunResult::Pass, "respawn succeeded"),
450 ),
451 ev(
452 9,
453 crate::events::EventKind::FeatureCompleted {
454 feature_id: "f-1-1".to_string(),
455 commits: vec!["deadbeef".to_string()],
456 },
457 ),
458 ev(
459 10,
460 crate::events::EventKind::MilestoneCompleted {
461 milestone_id: "ms-1".to_string(),
462 tag: None,
463 },
464 ),
465 ];
466 let state = fold(&events).unwrap();
467
468 let pairs = export_validated_traces(&state, &events);
469
470 assert_eq!(
471 pairs.len(),
472 1,
473 "expected exactly one passed trace, not the failed respawn attempt: {pairs:?}"
474 );
475 assert_eq!(pairs[0].run_id, "r-attempt-2");
476 assert!(pairs.iter().all(|p| p.run_id != "r-attempt-1"));
477 }
478
479 #[test]
480 fn regenerable() {
481 let events = fixture_events();
482 let state = fold(&events).unwrap();
483
484 let first = to_jsonl(&export_validated_traces(&state, &events));
485 let second = to_jsonl(&export_validated_traces(&state, &events));
486
487 assert_eq!(first, second, "export must be byte-identical across calls");
488 assert!(!first.is_empty());
489
490 let restate = fold(&events).unwrap();
493 let third = to_jsonl(&export_validated_traces(&restate, &events));
494 assert_eq!(first, third);
495 }
496
497 #[test]
498 fn instruction_pair() {
499 let events = fixture_events();
500 let state = fold(&events).unwrap();
501 let jsonl = to_jsonl(&export_validated_traces(&state, &events));
502
503 let lines: Vec<&str> = jsonl.lines().collect();
504 assert_eq!(lines.len(), 1);
505 for line in lines {
506 let value: serde_json::Value = serde_json::from_str(line)
507 .unwrap_or_else(|e| panic!("line is not valid JSON: {e}: {line}"));
508 assert!(value["instruction"].is_string());
509 assert!(value["response"].is_string());
510 assert_eq!(value["model"], "sonnet");
511 assert_eq!(value["quant"], "n/a");
512 assert!(value.get("weightHash").is_none());
513 }
514 }
515
516 #[cfg(unix)]
519 #[test]
520 fn export_output_refuses_to_write_through_a_symlink() {
521 let tmp = tempfile::tempdir().unwrap();
522 let target = tmp.path().join("authorized_keys");
523 std::fs::write(&target, "ssh-ed25519 REAL\n").unwrap();
524 let out = tmp.path().join("corpus.jsonl");
525 std::os::unix::fs::symlink(&target, &out).unwrap();
526
527 let error = write_export_output(&out, b"{}\n").unwrap_err();
528 assert!(
529 error.to_string().contains("refusing"),
530 "unexpected error: {error}"
531 );
532 assert_eq!(
533 std::fs::read_to_string(&target).unwrap(),
534 "ssh-ed25519 REAL\n",
535 "the symlink target must be untouched"
536 );
537 }
538
539 #[test]
540 fn export_output_writes_and_replaces_a_regular_file() {
541 let tmp = tempfile::tempdir().unwrap();
542 let out = tmp.path().join("nested").join("corpus.jsonl");
543 std::fs::create_dir_all(out.parent().unwrap()).unwrap();
544
545 write_export_output(&out, b"first\n").unwrap();
546 assert_eq!(std::fs::read_to_string(&out).unwrap(), "first\n");
547 write_export_output(&out, b"second\n").unwrap();
548 assert_eq!(std::fs::read_to_string(&out).unwrap(), "second\n");
549 let leftovers: Vec<_> = std::fs::read_dir(out.parent().unwrap())
551 .unwrap()
552 .flatten()
553 .map(|entry| entry.file_name().to_string_lossy().to_string())
554 .filter(|name| name.contains("kranz-export"))
555 .collect();
556 assert!(leftovers.is_empty(), "temp files left: {leftovers:?}");
557 }
558}