1use 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
40pub 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#[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 #[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#[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#[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
113pub 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
197pub 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 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; 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
609pub 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
694pub 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; }
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
778pub 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 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 assert_eq!(fixture.manifest.release.next_version, "0.7.53");
1005
1006 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 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 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 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 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 let err = ingest_release_fixture(temp.path(), CrystallizeOptions::default()).unwrap_err();
1114 assert!(format!("{err}").contains("zero actions"));
1115 }
1116}