Skip to main content

harn_vm/orchestration/
release_fixture.rs

1//! Release-harness fixture ingest.
2//!
3//! `release_harn.harn` (in `harn-bump-fleet`) emits a
4//! `release_harn.crystallization_input.v1` fixture bundle for every run:
5//!
6//! ```text
7//! crystallization-input/
8//!   manifest.json                # release identity + file map
9//!   release-run.json             # full release-harness payload
10//!   deterministic-events.jsonl   # harness facts/findings/step records
11//!   agent-events.jsonl           # model review/changelog/recovery events
12//!   tool-observations.jsonl      # shell/read/tool observations
13//!   README.md                    # human-readable description
14//! ```
15//!
16//! This module ingests one of those bundles and synthesizes a single
17//! crystallization candidate (no repeated-sequence mining required —
18//! the trace IS the workflow). Output is a [`CrystallizationArtifacts`]
19//! that downstream tooling (`build_crystallization_bundle`,
20//! `validate_crystallization_bundle`, `shadow_replay_bundle`) consumes
21//! unchanged.
22//!
23//! See `harn-bump-fleet/release_harn.harn` for the upstream emitter and
24//! `docs/src/workflow-crystallization.md` for the steel-thread story.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value as JsonValue};
31use sha2::{Digest, Sha256};
32
33use super::crystallize::{
34    synthesize_candidate_from_trace, CrystallizationAction, CrystallizationApproval,
35    CrystallizationArtifacts, CrystallizationSideEffect, CrystallizationTrace, CrystallizeOptions,
36    RecoveryFeedbackSummary, SegmentSummary, WorkflowCandidateParameter,
37};
38use crate::value::VmError;
39
40/// Schema marker emitted by `release_harn.harn` on every event/manifest
41/// row. The trailing `.vN` carries the version. Importers MUST refuse
42/// fixtures with any other schema marker — bumping the suffix is how
43/// `release_harn.harn` signals a backwards-incompatible fixture shape
44/// change.
45pub const RELEASE_FIXTURE_SCHEMA: &str = "release_harn.crystallization_input.v1";
46
47const MANIFEST_FILE: &str = "manifest.json";
48const DETERMINISTIC_EVENTS_FILE: &str = "deterministic-events.jsonl";
49const AGENT_EVENTS_FILE: &str = "agent-events.jsonl";
50const TOOL_OBSERVATIONS_FILE: &str = "tool-observations.jsonl";
51
52/// Decoded release-fixture manifest. We keep this struct intentionally
53/// small — only the fields the ingester actually depends on — and
54/// preserve unknown fields verbatim in [`ReleaseFixtureManifest::raw`]
55/// so future schema additions don't lose information when the fixture
56/// is round-tripped.
57#[derive(Clone, Debug, Default, Serialize, Deserialize)]
58pub struct ReleaseFixtureManifest {
59    pub schema_version: String,
60    pub kind: String,
61    pub generated_by: String,
62    pub generated_at: String,
63    pub run_id: String,
64    pub source_issue: Option<String>,
65    pub consumer_issue: Option<String>,
66    pub release: ReleaseFixtureIdentity,
67    /// The full untouched manifest JSON. Useful for downstream
68    /// consumers and for round-tripping unknown fields without losing
69    /// them.
70    #[serde(skip_serializing, skip_deserializing)]
71    pub raw: Option<JsonValue>,
72}
73
74#[derive(Clone, Debug, Default, Serialize, Deserialize)]
75pub struct ReleaseFixtureIdentity {
76    pub repo: String,
77    pub mode: String,
78    #[serde(default)]
79    pub mock: bool,
80    pub current_version: String,
81    pub next_version: String,
82    pub starting_branch: String,
83    pub target_release_branch: String,
84    pub base_branch: String,
85    pub latest_tag: String,
86}
87
88/// One row from one of the JSONL fixtures (deterministic-events,
89/// agent-events, tool-observations).
90#[derive(Clone, Debug, Default, Serialize, Deserialize)]
91pub struct ReleaseFixtureEvent {
92    pub schema_version: String,
93    pub event_type: String,
94    pub source: String,
95    #[serde(default)]
96    pub timestamp: Option<String>,
97    #[serde(default)]
98    pub data: JsonValue,
99}
100
101/// All decoded data from one ingested fixture bundle. Kept distinct
102/// from the eventual [`CrystallizationTrace`] so the importer can run
103/// secondary analysis (segment summary, recovery summary) over the
104/// original event stream.
105#[derive(Clone, Debug, Default)]
106pub struct ReleaseFixture {
107    pub manifest: ReleaseFixtureManifest,
108    pub deterministic_events: Vec<ReleaseFixtureEvent>,
109    pub agent_events: Vec<ReleaseFixtureEvent>,
110    pub tool_observations: Vec<ReleaseFixtureEvent>,
111}
112
113/// Read a release-fixture bundle directory from disk. The manifest is
114/// required; missing JSONL streams are treated as empty (so a fixture
115/// from a release with no agent review still ingests cleanly).
116pub fn load_release_fixture(dir: &Path) -> Result<ReleaseFixture, VmError> {
117    let manifest_path = dir.join(MANIFEST_FILE);
118    let manifest_bytes = std::fs::read(&manifest_path).map_err(|error| {
119        VmError::Runtime(format!(
120            "failed to read release fixture manifest {}: {error}",
121            manifest_path.display()
122        ))
123    })?;
124    let manifest_json: JsonValue = serde_json::from_slice(&manifest_bytes).map_err(|error| {
125        VmError::Runtime(format!(
126            "failed to parse release fixture manifest {}: {error}",
127            manifest_path.display()
128        ))
129    })?;
130    let mut manifest: ReleaseFixtureManifest = serde_json::from_value(manifest_json.clone())
131        .map_err(|error| {
132            VmError::Runtime(format!(
133                "failed to decode release fixture manifest {}: {error}",
134                manifest_path.display()
135            ))
136        })?;
137    manifest.raw = Some(manifest_json);
138
139    if manifest.schema_version != RELEASE_FIXTURE_SCHEMA {
140        return Err(VmError::Runtime(format!(
141            "release fixture manifest {} has unrecognized schema_version {:?} (expected {})",
142            manifest_path.display(),
143            manifest.schema_version,
144            RELEASE_FIXTURE_SCHEMA
145        )));
146    }
147
148    let deterministic_events = load_optional_jsonl(&dir.join(DETERMINISTIC_EVENTS_FILE))?;
149    let agent_events = load_optional_jsonl(&dir.join(AGENT_EVENTS_FILE))?;
150    let tool_observations = load_optional_jsonl(&dir.join(TOOL_OBSERVATIONS_FILE))?;
151
152    Ok(ReleaseFixture {
153        manifest,
154        deterministic_events,
155        agent_events,
156        tool_observations,
157    })
158}
159
160fn load_optional_jsonl(path: &Path) -> Result<Vec<ReleaseFixtureEvent>, VmError> {
161    if !path.exists() {
162        return Ok(Vec::new());
163    }
164    let content = std::fs::read_to_string(path).map_err(|error| {
165        VmError::Runtime(format!(
166            "failed to read release fixture stream {}: {error}",
167            path.display()
168        ))
169    })?;
170    let mut events = Vec::new();
171    for (line_idx, line) in content.lines().enumerate() {
172        let trimmed = line.trim();
173        if trimmed.is_empty() {
174            continue;
175        }
176        let event: ReleaseFixtureEvent = serde_json::from_str(trimmed).map_err(|error| {
177            VmError::Runtime(format!(
178                "failed to decode release fixture event {} line {}: {error}",
179                path.display(),
180                line_idx + 1
181            ))
182        })?;
183        if event.schema_version != RELEASE_FIXTURE_SCHEMA {
184            return Err(VmError::Runtime(format!(
185                "release fixture event {} line {} has unrecognized schema {:?} (expected {})",
186                path.display(),
187                line_idx + 1,
188                event.schema_version,
189                RELEASE_FIXTURE_SCHEMA
190            )));
191        }
192        events.push(event);
193    }
194    Ok(events)
195}
196
197/// Convert a release fixture into a single [`CrystallizationTrace`].
198/// Each release-step / tool-observation / agent-attempt /
199/// recovery-advice event becomes one [`CrystallizationAction`], in
200/// timestamp order. Side effects are inferred from
201/// `release_step_recorded` events; capabilities and approval boundaries
202/// are derived from event type + tool kind.
203pub fn release_fixture_to_trace(fixture: &ReleaseFixture) -> CrystallizationTrace {
204    let release = &fixture.manifest.release;
205    let mut metadata: BTreeMap<String, JsonValue> = BTreeMap::new();
206    metadata.insert("release.repo".to_string(), json!(release.repo));
207    metadata.insert("release.mode".to_string(), json!(release.mode));
208    metadata.insert("release.mock".to_string(), json!(release.mock));
209    metadata.insert(
210        "release.current_version".to_string(),
211        json!(release.current_version),
212    );
213    metadata.insert(
214        "release.next_version".to_string(),
215        json!(release.next_version),
216    );
217    metadata.insert(
218        "release.starting_branch".to_string(),
219        json!(release.starting_branch),
220    );
221    metadata.insert(
222        "release.target_release_branch".to_string(),
223        json!(release.target_release_branch),
224    );
225    metadata.insert(
226        "release.base_branch".to_string(),
227        json!(release.base_branch),
228    );
229    metadata.insert("release.latest_tag".to_string(), json!(release.latest_tag));
230    metadata.insert("fixture.run_id".to_string(), json!(fixture.manifest.run_id));
231    metadata.insert(
232        "fixture.generated_by".to_string(),
233        json!(fixture.manifest.generated_by),
234    );
235
236    let mut actions = Vec::new();
237    for event in &fixture.deterministic_events {
238        if let Some(action) = deterministic_event_to_action(event, release) {
239            actions.push(action);
240        }
241    }
242    for event in &fixture.tool_observations {
243        if let Some(action) = tool_observation_to_action(event, release) {
244            actions.push(action);
245        }
246    }
247    for event in &fixture.agent_events {
248        if let Some(action) = agent_event_to_action(event, release) {
249            actions.push(action);
250        }
251    }
252
253    actions.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
254    // Re-key after sort so action ids stay deterministic and ordered.
255    for (idx, action) in actions.iter_mut().enumerate() {
256        if action.id.trim().is_empty() {
257            action.id = format!("event_{}", idx + 1);
258        }
259    }
260
261    let trace_id = if fixture.manifest.run_id.trim().is_empty() {
262        format!(
263            "release_fixture_{}",
264            hash_short(fixture.manifest.generated_at.as_bytes())
265        )
266    } else {
267        format!("release_fixture_{}", fixture.manifest.run_id)
268    };
269
270    CrystallizationTrace {
271        version: 1,
272        id: trace_id,
273        source: Some(format!(
274            "release_harn.harn run {} ({} -> {})",
275            fixture.manifest.run_id, release.current_version, release.next_version
276        )),
277        source_hash: None,
278        workflow_id: Some("release_harn".to_string()),
279        started_at: actions.first().and_then(|action| action.timestamp.clone()),
280        finished_at: actions.last().and_then(|action| action.timestamp.clone()),
281        flow: None,
282        actions,
283        replay_run: None,
284        replay_allowlist: Vec::new(),
285        usage: Default::default(),
286        metadata,
287    }
288}
289
290fn deterministic_event_to_action(
291    event: &ReleaseFixtureEvent,
292    release: &ReleaseFixtureIdentity,
293) -> Option<CrystallizationAction> {
294    let mut action = base_action_from_event(event);
295    let data = &event.data;
296    match event.event_type.as_str() {
297        "release_analysis" => {
298            action.kind = "release_analysis".to_string();
299            action.name = format!(
300                "analyze_release[{}->{}]",
301                data.get("current_version")
302                    .and_then(JsonValue::as_str)
303                    .unwrap_or(&release.current_version),
304                data.get("next_version")
305                    .and_then(JsonValue::as_str)
306                    .unwrap_or(&release.next_version),
307            );
308            action.parameters = release_parameters(release);
309            action.deterministic = Some(true);
310            action.fuzzy = Some(false);
311            action.capabilities = vec!["git.read".to_string(), "fs.read".to_string()];
312            action.inputs = data.clone();
313        }
314        "changelog_audit_inputs" => {
315            action.kind = "changelog_audit".to_string();
316            action.name = "collect_changelog_audit_inputs".to_string();
317            action.parameters = release_parameters(release);
318            action.deterministic = Some(true);
319            action.fuzzy = Some(false);
320            action.capabilities = vec!["fs.read".to_string()];
321            action.inputs = data.clone();
322        }
323        "deterministic_finding" => {
324            let finding = data
325                .get("finding")
326                .and_then(JsonValue::as_str)
327                .unwrap_or("finding");
328            action.kind = "release_finding".to_string();
329            action.name = format!("finding[{}]", short_summary(finding, 32));
330            action.deterministic = Some(true);
331            action.fuzzy = Some(false);
332            action.observed_output = Some(json!({"finding": finding}));
333        }
334        "release_step_recorded" => {
335            let step_name = data
336                .get("name")
337                .and_then(JsonValue::as_str)
338                .unwrap_or("step")
339                .to_string();
340            let command = data
341                .get("command")
342                .and_then(JsonValue::as_str)
343                .unwrap_or("")
344                .to_string();
345            let success = data
346                .get("success")
347                .and_then(JsonValue::as_bool)
348                .unwrap_or(true);
349            let status = data.get("status").cloned().unwrap_or(json!(0));
350            let classification = data
351                .get("classification")
352                .and_then(JsonValue::as_str)
353                .unwrap_or("");
354            let recovery_hint = data
355                .get("recovery_hint")
356                .and_then(JsonValue::as_str)
357                .unwrap_or("");
358            action.kind = if success {
359                "release_step".to_string()
360            } else {
361                "shell_failure".to_string()
362            };
363            action.name = sanitize_step_name(&step_name);
364            action.deterministic = Some(true);
365            action.fuzzy = Some(false);
366            action.parameters = BTreeMap::from([
367                ("step".to_string(), json!(step_name)),
368                ("command".to_string(), json!(command)),
369            ]);
370            action.inputs = json!({"command": command});
371            action.observed_output = Some(json!({"status": status, "success": success}));
372            if !success {
373                action.side_effects.push(CrystallizationSideEffect {
374                    kind: "shell_failure".to_string(),
375                    target: step_name.clone(),
376                    capability: Some("shell.exec".to_string()),
377                    mutation: None,
378                    metadata: BTreeMap::from([(
379                        "classification".to_string(),
380                        json!(classification),
381                    )]),
382                });
383            }
384            action
385                .metadata
386                .insert("classification".to_string(), json!(classification));
387            action
388                .metadata
389                .insert("recovery_hint".to_string(), json!(recovery_hint));
390            action
391                .metadata
392                .insert("success".to_string(), json!(success));
393        }
394        _ => return None,
395    }
396    Some(action)
397}
398
399fn tool_observation_to_action(
400    event: &ReleaseFixtureEvent,
401    release: &ReleaseFixtureIdentity,
402) -> Option<CrystallizationAction> {
403    if event.event_type != "tool_observation" {
404        return None;
405    }
406    let mut action = base_action_from_event(event);
407    let data = &event.data;
408    let tool = data
409        .get("tool")
410        .and_then(JsonValue::as_str)
411        .unwrap_or("shell");
412    let command = data
413        .get("command")
414        .and_then(JsonValue::as_str)
415        .unwrap_or("");
416    let path = data.get("path").and_then(JsonValue::as_str);
417    let step_name = data.get("step_name").and_then(JsonValue::as_str);
418    let success = data
419        .get("success")
420        .and_then(JsonValue::as_bool)
421        .unwrap_or(true);
422
423    action.kind = format!("tool_observation:{tool}");
424    action.name = match (tool, step_name) {
425        (_, Some(name)) if !name.is_empty() => sanitize_step_name(name),
426        ("read_file", Some(_)) | ("read_file", None) => format!(
427            "read_file[{}]",
428            short_summary(path.unwrap_or("unknown"), 48)
429        ),
430        ("shell", _) => format!("shell[{}]", short_summary(command, 48)),
431        _ => format!("{tool}[{}]", short_summary(command, 48)),
432    };
433    action.deterministic = Some(event.source != "agent");
434    action.fuzzy = Some(false);
435    let _ = release; // release identity only enters via metadata above
436    action.inputs = data.clone();
437    action.observed_output = Some(json!({
438        "stdout": data.get("stdout").cloned().unwrap_or(json!("")),
439        "stderr": data.get("stderr").cloned().unwrap_or(json!("")),
440        "status": data.get("status").cloned().unwrap_or(json!(0)),
441        "success": success,
442    }));
443    if !success {
444        action.side_effects.push(CrystallizationSideEffect {
445            kind: "tool_failure".to_string(),
446            target: step_name.unwrap_or(tool).to_string(),
447            capability: Some("shell.exec".to_string()),
448            mutation: None,
449            metadata: BTreeMap::default(),
450        });
451    }
452    action.metadata.insert("tool".to_string(), json!(tool));
453    Some(action)
454}
455
456fn agent_event_to_action(
457    event: &ReleaseFixtureEvent,
458    release: &ReleaseFixtureIdentity,
459) -> Option<CrystallizationAction> {
460    let mut action = base_action_from_event(event);
461    let data = &event.data;
462    match event.event_type.as_str() {
463        "agent_review_attempt" => {
464            let name = data
465                .get("name")
466                .and_then(JsonValue::as_str)
467                .unwrap_or("review");
468            action.kind = "agent_review_attempt".to_string();
469            action.name = format!("agent_review[{}]", sanitize_step_name(name));
470            action.fuzzy = Some(true);
471            action.deterministic = Some(false);
472            action.cost.model_calls = 1;
473            action.parameters = release_parameters(release);
474            action.inputs = data.clone();
475            action.observed_output = data.get("text").cloned();
476            action.approval = Some(CrystallizationApproval {
477                prompt: Some(
478                    "Human reviewer must accept the agent-authored audit before promotion."
479                        .to_string(),
480                ),
481                approver: None,
482                required: true,
483                boundary: Some("release_audit_review".to_string()),
484            });
485        }
486        "agent_review_artifacts" => {
487            action.kind = "agent_review_artifacts".to_string();
488            action.name = "persist_agent_review_artifacts".to_string();
489            action.deterministic = Some(true);
490            action.fuzzy = Some(false);
491            action.observed_output = Some(data.clone());
492        }
493        "agent_recovery_advice" => {
494            let step_name = data
495                .get("step_name")
496                .and_then(JsonValue::as_str)
497                .unwrap_or("recovery");
498            action.kind = "agent_recovery_advice".to_string();
499            action.name = format!("recovery_advice[{}]", sanitize_step_name(step_name));
500            action.fuzzy = Some(true);
501            action.deterministic = Some(false);
502            action.cost.model_calls = 1;
503            action.observed_output = data.get("text").cloned();
504            action.parameters = BTreeMap::from([("failed_step".to_string(), json!(step_name))]);
505            action.approval = Some(CrystallizationApproval {
506                prompt: Some(
507                    "Human reviewer must validate recovery advice before re-running the failing step."
508                        .to_string(),
509                ),
510                approver: None,
511                required: true,
512                boundary: Some("recovery_review".to_string()),
513            });
514            action
515                .metadata
516                .insert("failed_step".to_string(), json!(step_name));
517        }
518        "agent_review_skipped" => {
519            action.kind = "agent_review_skipped".to_string();
520            action.name = "agent_review_skipped".to_string();
521            action.deterministic = Some(true);
522            action.fuzzy = Some(false);
523            action.observed_output = data.get("reason").cloned();
524        }
525        _ => return None,
526    }
527    Some(action)
528}
529
530fn base_action_from_event(event: &ReleaseFixtureEvent) -> CrystallizationAction {
531    CrystallizationAction {
532        id: stable_event_id(event),
533        timestamp: event.timestamp.clone(),
534        ..CrystallizationAction::default()
535    }
536}
537
538fn stable_event_id(event: &ReleaseFixtureEvent) -> String {
539    let mut hasher = Sha256::new();
540    hasher.update(event.event_type.as_bytes());
541    hasher.update([0]);
542    hasher.update(event.source.as_bytes());
543    hasher.update([0]);
544    if let Some(timestamp) = &event.timestamp {
545        hasher.update(timestamp.as_bytes());
546    }
547    hasher.update([0]);
548    hasher.update(event.data.to_string().as_bytes());
549    let hex = hex::encode(hasher.finalize());
550    format!(
551        "evt_{}_{}",
552        sanitize_step_name(&event.event_type),
553        hex.chars().take(12).collect::<String>()
554    )
555}
556
557fn release_parameters(release: &ReleaseFixtureIdentity) -> BTreeMap<String, JsonValue> {
558    let mut out = BTreeMap::new();
559    out.insert("repo".to_string(), json!(release.repo));
560    out.insert("base_branch".to_string(), json!(release.base_branch));
561    out.insert(
562        "current_version".to_string(),
563        json!(release.current_version),
564    );
565    out.insert("next_version".to_string(), json!(release.next_version));
566    out.insert(
567        "target_release_branch".to_string(),
568        json!(release.target_release_branch),
569    );
570    out
571}
572
573fn sanitize_step_name(raw: &str) -> String {
574    let cleaned: String = raw
575        .chars()
576        .map(|ch| {
577            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
578                ch.to_ascii_lowercase()
579            } else {
580                '_'
581            }
582        })
583        .collect();
584    let trimmed = cleaned.trim_matches('_').to_string();
585    if trimmed.is_empty() {
586        "step".to_string()
587    } else {
588        trimmed
589    }
590}
591
592fn short_summary(raw: &str, max_chars: usize) -> String {
593    let cleaned = raw.replace(['\n', '\r', '\t'], " ");
594    let trimmed = cleaned.trim();
595    if trimmed.chars().count() <= max_chars {
596        trimmed.to_string()
597    } else {
598        let prefix: String = trimmed.chars().take(max_chars - 1).collect();
599        format!("{prefix}…")
600    }
601}
602
603fn hash_short(bytes: &[u8]) -> String {
604    let mut hasher = Sha256::new();
605    hasher.update(bytes);
606    hex::encode(hasher.finalize()).chars().take(12).collect()
607}
608
609/// Build a [`SegmentSummary`] from an ingested fixture. Splits steps
610/// into "safe to automate" (deterministic events with no shell
611/// failures) vs. "requires human review" (agent events, shell
612/// failures, recovery advice).
613pub fn build_segment_summary(fixture: &ReleaseFixture) -> SegmentSummary {
614    let mut safe = Vec::new();
615    let mut review = Vec::new();
616    for event in &fixture.deterministic_events {
617        match event.event_type.as_str() {
618            "release_analysis" | "changelog_audit_inputs" => safe.push(event.event_type.clone()),
619            "release_step_recorded" => {
620                let name = event
621                    .data
622                    .get("name")
623                    .and_then(JsonValue::as_str)
624                    .unwrap_or("release_step")
625                    .to_string();
626                let success = event
627                    .data
628                    .get("success")
629                    .and_then(JsonValue::as_bool)
630                    .unwrap_or(true);
631                if success {
632                    safe.push(format!("step:{name}"));
633                } else {
634                    review.push(format!(
635                        "failed step:{name} (deterministic recovery required before re-run)"
636                    ));
637                }
638            }
639            "deterministic_finding" => {
640                let finding = event
641                    .data
642                    .get("finding")
643                    .and_then(JsonValue::as_str)
644                    .unwrap_or("finding");
645                review.push(format!("finding requires reviewer attention: {finding}"));
646            }
647            _ => {}
648        }
649    }
650    for event in &fixture.agent_events {
651        match event.event_type.as_str() {
652            "agent_review_attempt" => {
653                let name = event
654                    .data
655                    .get("name")
656                    .and_then(JsonValue::as_str)
657                    .unwrap_or("agent_review");
658                review.push(format!(
659                    "agent review attempt:{name} (model-authored, advisory only)"
660                ));
661            }
662            "agent_recovery_advice" => {
663                let step = event
664                    .data
665                    .get("step_name")
666                    .and_then(JsonValue::as_str)
667                    .unwrap_or("step");
668                review.push(format!(
669                    "agent recovery advice for {step} (must be validated before re-run)"
670                ));
671            }
672            _ => {}
673        }
674    }
675    let plain = format!(
676        "Safe to automate: {} deterministic event(s) (release analysis, changelog inputs, \
677         successful release steps). Requires human/agent review: {} item(s) including \
678         agent-authored audits, model recovery advice, and any failed deterministic steps. \
679         The crystallized workflow keeps all agent steps behind explicit approval boundaries; \
680         hosts should not promote this candidate to fully autonomous execution without first \
681         resolving the review-required entries.",
682        safe.len(),
683        review.len()
684    );
685    SegmentSummary {
686        deterministic_count: safe.len(),
687        agentic_count: review.len(),
688        safe_to_automate: safe,
689        requires_human_review: review,
690        plain_language: plain,
691    }
692}
693
694/// Build a [`RecoveryFeedbackSummary`] from an ingested fixture. Counts
695/// failed `release_step_recorded` events (the canonical failure record
696/// in `release_harn.harn`) and recovery-advice runs, then emits a
697/// plain-language description of how recovery is represented.
698///
699/// The mirrored `tool_observation` rows for the same release steps are
700/// only consulted for failures whose step name does not already appear
701/// in the deterministic stream — that way a per-step failure is counted
702/// exactly once, but a tool observation that is not paired with a
703/// release-step row (e.g. a one-off shell read failure) still counts.
704pub fn build_recovery_summary(fixture: &ReleaseFixture) -> RecoveryFeedbackSummary {
705    let mut shell_failures = 0usize;
706    let mut failed_steps: Vec<String> = Vec::new();
707    let mut counted_step_names: BTreeSet<String> = BTreeSet::new();
708
709    for event in fixture
710        .deterministic_events
711        .iter()
712        .chain(fixture.tool_observations.iter())
713    {
714        let success = event
715            .data
716            .get("success")
717            .and_then(JsonValue::as_bool)
718            .unwrap_or(true);
719        if success {
720            continue;
721        }
722        let name = event
723            .data
724            .get("name")
725            .or_else(|| event.data.get("step_name"))
726            .and_then(JsonValue::as_str);
727        if let Some(name) = name {
728            if !counted_step_names.insert(name.to_string()) {
729                continue; // already counted from the deterministic stream
730            }
731            if !failed_steps.iter().any(|n| n == name) {
732                failed_steps.push(name.to_string());
733            }
734        }
735        shell_failures += 1;
736    }
737    let recovery_runs = fixture
738        .agent_events
739        .iter()
740        .filter(|event| event.event_type == "agent_recovery_advice")
741        .count();
742    let fed = recovery_runs > 0 && shell_failures > 0;
743    let representation = if fed {
744        format!(
745            "{} shell/tool failure(s) were observed and {} of them triggered an `agent_loop` \
746             recovery advice run. Failure stdout/stderr, classification, and recovery hints from \
747             the failing release step were embedded in the model prompt; the model produced \
748             advisory text only — no automatic re-run or live mutation. Reviewers must validate \
749             the advice before re-running the failing step.",
750            shell_failures, recovery_runs
751        )
752    } else if shell_failures > 0 {
753        format!(
754            "{} shell/tool failure(s) were observed but no agent recovery loop was invoked. \
755             Failure context was recorded for human review only.",
756            shell_failures
757        )
758    } else if recovery_runs > 0 {
759        format!(
760            "No shell/tool failures were observed in this run, but {} agent recovery loop(s) \
761             ran (likely from a previous attempt or a non-shell error path).",
762            recovery_runs
763        )
764    } else {
765        "No shell/tool failures were observed and no recovery loops ran. The release \
766         executed end-to-end on the deterministic path."
767            .to_string()
768    };
769    RecoveryFeedbackSummary {
770        shell_failures_seen: shell_failures,
771        recovery_advice_runs: recovery_runs,
772        failures_fed_into_agent: fed,
773        failed_steps,
774        representation,
775    }
776}
777
778/// Top-level convenience: read a fixture from disk and synthesize a
779/// crystallization candidate ready for bundle-build / validate /
780/// shadow-replay. This is what the CLI subcommand wires up.
781pub fn ingest_release_fixture(
782    dir: &Path,
783    options: CrystallizeOptions,
784) -> Result<
785    (
786        CrystallizationArtifacts,
787        ReleaseFixture,
788        CrystallizationTrace,
789    ),
790    VmError,
791> {
792    let fixture = load_release_fixture(dir)?;
793    let mut trace = release_fixture_to_trace(&fixture);
794    if trace.actions.is_empty() {
795        return Err(VmError::Runtime(format!(
796            "release fixture {} produced zero actions; nothing to crystallize",
797            dir.display()
798        )));
799    }
800    // Stamp a content hash on the trace so the manifest's
801    // source_trace_hashes line up with downstream eval pack rubric
802    // expectations even before normalize_trace re-fills it.
803    let payload = serde_json::to_vec(&trace.actions).unwrap_or_default();
804    trace.source_hash = Some(format!(
805        "sha256:{}",
806        hex::encode(Sha256::digest(payload.as_slice()))
807    ));
808
809    let segment_summary = build_segment_summary(&fixture);
810    let recovery_summary = build_recovery_summary(&fixture);
811    let release = &fixture.manifest.release;
812    let extra_parameters = release_parameter_definitions(release);
813
814    let artifacts = synthesize_candidate_from_trace(
815        trace.clone(),
816        options,
817        extra_parameters,
818        Some(segment_summary),
819        Some(recovery_summary),
820    )?;
821    Ok((artifacts, fixture, trace))
822}
823
824fn release_parameter_definitions(
825    release: &ReleaseFixtureIdentity,
826) -> Vec<WorkflowCandidateParameter> {
827    fn parameter(name: &str, value: &str) -> WorkflowCandidateParameter {
828        WorkflowCandidateParameter {
829            name: name.to_string(),
830            source_paths: vec![format!("manifest.release.{name}")],
831            examples: if value.trim().is_empty() {
832                Vec::new()
833            } else {
834                vec![value.to_string()]
835            },
836            required: true,
837        }
838    }
839    vec![
840        parameter("repo", &release.repo),
841        parameter("base_branch", &release.base_branch),
842        parameter("current_version", &release.current_version),
843        parameter("next_version", &release.next_version),
844        parameter("target_release_branch", &release.target_release_branch),
845    ]
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use std::fs;
852    use tempfile::TempDir;
853
854    fn write_fixture(dir: &Path) {
855        let manifest = json!({
856            "schema_version": RELEASE_FIXTURE_SCHEMA,
857            "kind": "release_harn_crystallization_input",
858            "generated_by": "release_harn.harn",
859            "generated_at": "2026-05-02T00:00:00Z",
860            "run_id": "test-run",
861            "source_issue": "https://github.com/burin-labs/harn-bump-fleet/issues/2",
862            "consumer_issue": "https://github.com/burin-labs/harn/issues/1146",
863            "release": {
864                "repo": "/tmp/harn",
865                "mode": "audit",
866                "mock": true,
867                "current_version": "0.7.52",
868                "next_version": "0.7.53",
869                "starting_branch": "main",
870                "target_release_branch": "release/v0.7.53",
871                "base_branch": "main",
872                "latest_tag": "v0.7.52"
873            }
874        });
875        fs::write(
876            dir.join(MANIFEST_FILE),
877            serde_json::to_string_pretty(&manifest).unwrap(),
878        )
879        .unwrap();
880
881        let det = [
882            json!({
883                "schema_version": RELEASE_FIXTURE_SCHEMA,
884                "event_type": "release_analysis",
885                "source": "deterministic",
886                "timestamp": "2026-05-02T00:00:00Z",
887                "data": {
888                    "current_version": "0.7.52",
889                    "next_version": "0.7.53",
890                    "base_branch": "main"
891                }
892            }),
893            json!({
894                "schema_version": RELEASE_FIXTURE_SCHEMA,
895                "event_type": "changelog_audit_inputs",
896                "source": "deterministic",
897                "timestamp": "2026-05-02T00:00:01Z",
898                "data": {"expected_version": "0.7.53"}
899            }),
900            json!({
901                "schema_version": RELEASE_FIXTURE_SCHEMA,
902                "event_type": "release_step_recorded",
903                "source": "deterministic",
904                "timestamp": "2026-05-02T00:00:02Z",
905                "data": {
906                    "name": "ensure-clean-tree",
907                    "command": "git status --porcelain",
908                    "success": true,
909                    "status": 0
910                }
911            }),
912            json!({
913                "schema_version": RELEASE_FIXTURE_SCHEMA,
914                "event_type": "release_step_recorded",
915                "source": "deterministic",
916                "timestamp": "2026-05-02T00:00:03Z",
917                "data": {
918                    "name": "push",
919                    "command": "git push -u origin release/v0.7.53",
920                    "success": false,
921                    "status": 1,
922                    "classification": "branch_protection",
923                    "recovery_hint": "rerun with --no-verify after green hook budget"
924                }
925            }),
926        ];
927        let det_jsonl: String = det
928            .iter()
929            .map(|v| serde_json::to_string(v).unwrap())
930            .collect::<Vec<_>>()
931            .join("\n");
932        fs::write(dir.join(DETERMINISTIC_EVENTS_FILE), det_jsonl + "\n").unwrap();
933
934        let agent = [
935            json!({
936                "schema_version": RELEASE_FIXTURE_SCHEMA,
937                "event_type": "agent_review_attempt",
938                "source": "agent",
939                "timestamp": "2026-05-02T00:00:04Z",
940                "data": {
941                    "index": 0,
942                    "name": "release_audit",
943                    "status": "ok",
944                    "text": "audit passes",
945                    "validation_findings": []
946                }
947            }),
948            json!({
949                "schema_version": RELEASE_FIXTURE_SCHEMA,
950                "event_type": "agent_recovery_advice",
951                "source": "agent",
952                "timestamp": "2026-05-02T00:00:05Z",
953                "data": {
954                    "step_name": "push",
955                    "status": "ok",
956                    "text": "Re-run with --no-verify"
957                }
958            }),
959        ];
960        let agent_jsonl: String = agent
961            .iter()
962            .map(|v| serde_json::to_string(v).unwrap())
963            .collect::<Vec<_>>()
964            .join("\n");
965        fs::write(dir.join(AGENT_EVENTS_FILE), agent_jsonl + "\n").unwrap();
966
967        let tools = [json!({
968            "schema_version": RELEASE_FIXTURE_SCHEMA,
969            "event_type": "tool_observation",
970            "source": "deterministic",
971            "timestamp": "2026-05-02T00:00:06Z",
972            "data": {
973                "tool": "shell",
974                "command": "git status --porcelain",
975                "success": true,
976                "status": 0,
977                "stdout": "",
978                "stderr": ""
979            }
980        })];
981        let tools_jsonl: String = tools
982            .iter()
983            .map(|v| serde_json::to_string(v).unwrap())
984            .collect::<Vec<_>>()
985            .join("\n");
986        fs::write(dir.join(TOOL_OBSERVATIONS_FILE), tools_jsonl + "\n").unwrap();
987    }
988
989    #[test]
990    fn ingest_emits_a_safe_candidate_with_segment_and_recovery_summary() {
991        let temp = TempDir::new().unwrap();
992        write_fixture(temp.path());
993        let (artifacts, fixture, _trace) = ingest_release_fixture(
994            temp.path(),
995            CrystallizeOptions {
996                workflow_name: Some("release_harn".to_string()),
997                package_name: Some("release-harn".to_string()),
998                ..CrystallizeOptions::default()
999            },
1000        )
1001        .expect("ingest");
1002
1003        // Manifest decoded.
1004        assert_eq!(fixture.manifest.release.next_version, "0.7.53");
1005
1006        // Candidate selected and safe to propose.
1007        assert!(artifacts.report.selected_candidate_id.is_some());
1008        let candidate = &artifacts.report.candidates[0];
1009        assert_eq!(candidate.name, "release_harn");
1010        assert!(candidate.shadow.pass);
1011
1012        // Segment summary records both deterministic and agentic items.
1013        let summary = artifacts
1014            .report
1015            .segment_summary
1016            .as_ref()
1017            .expect("segment summary");
1018        assert!(summary.deterministic_count >= 3);
1019        assert!(summary.agentic_count >= 2);
1020        assert!(summary
1021            .requires_human_review
1022            .iter()
1023            .any(|item| item.contains("failed step:push")));
1024
1025        // Recovery summary captures the failed push + advice.
1026        let recovery = artifacts
1027            .report
1028            .recovery_summary
1029            .as_ref()
1030            .expect("recovery summary");
1031        assert!(recovery.shell_failures_seen >= 1);
1032        assert!(recovery.recovery_advice_runs >= 1);
1033        assert!(recovery.failures_fed_into_agent);
1034        assert!(recovery.failed_steps.contains(&"push".to_string()));
1035        assert!(recovery.representation.contains("agent_loop"));
1036
1037        // Release identity surfaces as parameters with `next_version` etc.
1038        let names: Vec<&str> = candidate
1039            .parameters
1040            .iter()
1041            .map(|p| p.name.as_str())
1042            .collect();
1043        assert!(names.contains(&"next_version"));
1044        assert!(names.contains(&"current_version"));
1045        assert!(names.contains(&"base_branch"));
1046
1047        // Generated workflow code references the candidate name.
1048        assert!(artifacts.harn_code.contains("pipeline release_harn"));
1049    }
1050
1051    #[test]
1052    fn rejects_unknown_schema() {
1053        let temp = TempDir::new().unwrap();
1054        let manifest = json!({
1055            "schema_version": "release_harn.crystallization_input.v999",
1056            "kind": "release_harn_crystallization_input",
1057            "generated_by": "release_harn.harn",
1058            "generated_at": "2026-05-02T00:00:00Z",
1059            "run_id": "test-run",
1060            "release": {
1061                "repo": "/tmp/harn",
1062                "mode": "audit",
1063                "mock": true,
1064                "current_version": "0.7.52",
1065                "next_version": "0.7.53",
1066                "starting_branch": "main",
1067                "target_release_branch": "release/v0.7.53",
1068                "base_branch": "main",
1069                "latest_tag": "v0.7.52"
1070            }
1071        });
1072        fs::write(
1073            temp.path().join(MANIFEST_FILE),
1074            serde_json::to_string_pretty(&manifest).unwrap(),
1075        )
1076        .unwrap();
1077        let err = load_release_fixture(temp.path()).unwrap_err();
1078        assert!(format!("{err}").contains("unrecognized schema"));
1079    }
1080
1081    #[test]
1082    fn missing_jsonl_streams_default_to_empty() {
1083        let temp = TempDir::new().unwrap();
1084        let manifest = json!({
1085            "schema_version": RELEASE_FIXTURE_SCHEMA,
1086            "kind": "release_harn_crystallization_input",
1087            "generated_by": "release_harn.harn",
1088            "generated_at": "2026-05-02T00:00:00Z",
1089            "run_id": "no-events-run",
1090            "release": {
1091                "repo": "/tmp/harn",
1092                "mode": "audit",
1093                "mock": true,
1094                "current_version": "0.7.52",
1095                "next_version": "0.7.53",
1096                "starting_branch": "main",
1097                "target_release_branch": "release/v0.7.53",
1098                "base_branch": "main",
1099                "latest_tag": "v0.7.52"
1100            }
1101        });
1102        fs::write(
1103            temp.path().join(MANIFEST_FILE),
1104            serde_json::to_string_pretty(&manifest).unwrap(),
1105        )
1106        .unwrap();
1107        let fixture = load_release_fixture(temp.path()).expect("load");
1108        assert!(fixture.deterministic_events.is_empty());
1109        assert!(fixture.agent_events.is_empty());
1110        assert!(fixture.tool_observations.is_empty());
1111
1112        // Without any events, ingest fails closed (no actions to crystallize).
1113        let err = ingest_release_fixture(temp.path(), CrystallizeOptions::default()).unwrap_err();
1114        assert!(format!("{err}").contains("zero actions"));
1115    }
1116}