Skip to main content

kranz_engine/
trace_export.rs

1//! Derived, event-log-regenerable export of validation-PASSED worker traces
2//! in instruction-pair form (plan §M2). Mirrors the pattern of
3//! `orchestrator::render_mission_report`: a pure function over the folded
4//! [`MissionState`] with no second persisted source of truth. Calling
5//! `export_validated_traces`/`to_jsonl` again over the same log always
6//! yields the same bytes — there is nothing to regenerate FROM except the
7//! event log itself.
8
9use crate::events::Event;
10use crate::types::{
11    Feature, FeatureStatus, Milestone, MilestoneStatus, MissionState, Role, RunResult,
12};
13use serde::{Deserialize, Serialize};
14
15/// One fine-tuning-ready training example derived from a validation-PASSED
16/// worker run: the task it was given (instruction) and its accepted final
17/// report (response), carrying model provenance for dataset filtering.
18#[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
32/// The feature a run belongs to, plus the status of the milestone that owns
33/// it — `None` if the feature id isn't found in the current plan (e.g. a
34/// stale/foreign id).
35fn 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
47/// Derive the validation-passed instruction-pair dataset from folded mission
48/// state. Selection is validation-PASSED and DERIVED, never stored: a run
49/// qualifies iff it is a `Role::Worker` run whose feature reached
50/// `FeatureStatus::Complete` inside a milestone that reached
51/// `MilestoneStatus::Complete`, AND the run's own `result` is
52/// `Some(RunResult::Pass)`. Runs on failed/skipped features, non-worker
53/// (orchestrator/validator) runs, and failed respawn attempts that precede a
54/// later passing run on the same now-Complete feature, are all excluded.
55///
56/// `events` is accepted (unused today) to keep the signature honest about
57/// what the export is a function of — the event log — should a future
58/// revision need raw event data the fold doesn't retain (e.g. renders event
59/// timestamps); `state` alone is sufficient for the current instruction-pair
60/// shape since it is itself `fold(events)`.
61pub fn export_validated_traces(state: &MissionState, _events: &[Event]) -> Vec<InstructionPair> {
62    let mut pairs = Vec::new();
63    // state.runs is a BTreeMap, so this iterates in a stable, deterministic
64    // (sorted-by-run-id) order — required for byte-identical regeneration.
65    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
122/// Render instruction pairs as JSONL: one compact JSON object per line,
123/// each terminated by `\n`. Pure function of its input — no I/O, no
124/// persisted dataset file — so it is byte-identical across repeated calls
125/// on the same pairs.
126pub 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
135/// Write one export payload to an operator-named `--out` path: parent chain
136/// pinned no-follow, a symlinked (or otherwise non-regular) destination
137/// REFUSED, bytes landed through a sibling temp file and a rename.
138///
139/// Shared by `kranz export-traces --out` and `kranz export-corpus --out`,
140/// which both used a bare `std::fs::write`. That truncates THROUGH a
141/// symlink, so an agent could plant `corpus.jsonl` → `~/.ssh/authorized_keys`
142/// (or a git hook) in the repo root and have the operator's own export
143/// overwrite it with partly agent-authored JSONL. Refusing the link is the
144/// point; the atomic rename is the same discipline the queue and ticket
145/// writers already use.
146///
147/// Lives here rather than in `paths` only because both exports are the
148/// callers; it is a candidate to move next to the other no-follow helpers.
149pub 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    // A bare `corpus.jsonl` has an EMPTY parent, which no path helper can
162    // canonicalize; anchor it at the cwd first.
163    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        // POSIX rename replaces the destination NAME, so a link swapped in
199        // after the check is replaced, never written through. Windows needs
200        // the destination gone first.
201        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    /// One milestone, two features: f-1-1 (will pass) and f-1-2 (will fail).
255    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    /// Fixture: one Complete milestone with a passed feature (f-1-1, worker
316    /// run r-pass) and a failed feature (f-1-2, worker run r-fail).
317    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        // Single feature reaching Complete via TWO worker runs: a first
409        // attempt that fails (report present) and a respawned second
410        // attempt that passes. Only the passing run's pair may be exported.
411        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        // Independently regenerated from the same log — a second fold, not
491        // a cached/reused value — still matches byte-for-byte.
492        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    /// Audit M2: `--out` used a bare `std::fs::write`, which truncates
517    /// through a symlink an agent can plant at a plausible output path.
518    #[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        // No temp file left behind.
550        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}