use std::path::{Path, PathBuf};
use crate::flight_recorder::FlightRecordingArtifact;
use super::{RunEvidenceGapRecord, RunExecutionRecord, RunRecord};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecutionRecordStatus {
Completed,
Failed,
}
impl ExecutionRecordStatus {
fn as_str(self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FlightRecordingStorage<'a> {
Managed {
retain_files: usize,
},
Custom {
output: &'a Path,
},
}
#[derive(Clone, Copy, Debug)]
pub struct ExecutionRecordRequest<'a> {
pub source_path: &'a Path,
pub store_base: &'a Path,
pub adapter: &'a str,
pub status: ExecutionRecordStatus,
pub started_at: &'a str,
pub finished_at: &'a str,
pub flight_recording: Option<FlightRecordingStorage<'a>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PersistedExecutionRecord {
pub run_record_path: PathBuf,
pub flight_recording: Option<FlightRecordingArtifact>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecutionRecordPersistStage {
FlightRecording,
RunRecord,
}
impl ExecutionRecordPersistStage {
pub fn code(self) -> &'static str {
match self {
Self::FlightRecording => "flight_recording_persist",
Self::RunRecord => "run_record_persist",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("{summary}")]
pub struct ExecutionRecordPersistError {
stage: ExecutionRecordPersistStage,
summary: String,
diagnostics: Vec<String>,
flight_recording: Option<FlightRecordingArtifact>,
}
impl ExecutionRecordPersistError {
pub fn stage(&self) -> ExecutionRecordPersistStage {
self.stage
}
pub fn summary(&self) -> &str {
&self.summary
}
pub fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
pub fn flight_recording(&self) -> Option<&FlightRecordingArtifact> {
self.flight_recording.as_ref()
}
}
pub fn persist_execution_record(
vm: &crate::Vm,
request: ExecutionRecordRequest<'_>,
) -> Result<PersistedExecutionRecord, ExecutionRecordPersistError> {
let flight_recording = match persist_flight_recording(vm, &request) {
Ok(recording) => recording,
Err(error) => {
let mut diagnostics = vec![error.clone()];
match persist_run_record(
vm,
&request,
ExecutionRecordStatus::Failed,
None,
vec![RunEvidenceGapRecord {
component: "flight_recording".to_string(),
code: "persist_failed".to_string(),
message: error.clone(),
}],
) {
Ok(run_record_path) => emit_persisted(vm, &run_record_path, None),
Err(record_error) => diagnostics.push(record_error),
}
return Err(ExecutionRecordPersistError {
stage: ExecutionRecordPersistStage::FlightRecording,
summary: error,
diagnostics,
flight_recording: None,
});
}
};
let run_record_path = persist_run_record(
vm,
&request,
request.status,
flight_recording.as_ref(),
Vec::new(),
)
.map_err(|error| ExecutionRecordPersistError {
stage: ExecutionRecordPersistStage::RunRecord,
summary: error.clone(),
diagnostics: vec![error],
flight_recording: flight_recording.clone(),
})?;
emit_persisted(vm, &run_record_path, flight_recording.clone());
Ok(PersistedExecutionRecord {
run_record_path,
flight_recording,
})
}
fn persist_flight_recording(
vm: &crate::Vm,
request: &ExecutionRecordRequest<'_>,
) -> Result<Option<FlightRecordingArtifact>, String> {
let Some(options) = request.flight_recording else {
return Ok(None);
};
let recording = vm
.flight_recording()
.ok_or_else(|| "flight recorder was requested but not installed".to_string())?;
let (path, retain_files) = match options {
FlightRecordingStorage::Managed { retain_files } => (
crate::runtime_paths::run_root(request.store_base)
.join("flight-recordings")
.join(format!("{}.json", recording.execution_id)),
retain_files,
),
FlightRecordingStorage::Custom { output } => (output.to_path_buf(), usize::MAX),
};
crate::flight_recorder::persist_recording(&recording, &path, retain_files).map(Some)
}
fn persist_run_record(
vm: &crate::Vm,
request: &ExecutionRecordRequest<'_>,
status: ExecutionRecordStatus,
flight_recording: Option<&FlightRecordingArtifact>,
gaps: Vec<RunEvidenceGapRecord>,
) -> Result<PathBuf, String> {
let execution_id = vm.execution_id().to_string();
let run_path =
crate::runtime_paths::run_root(request.store_base).join(format!("{execution_id}.json"));
let run = RunRecord {
type_name: "run_record".to_string(),
id: execution_id.clone(),
workflow_id: "harn.execution".to_string(),
workflow_name: request
.source_path
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
task: request.source_path.to_string_lossy().into_owned(),
status: status.as_str().to_string(),
started_at: request.started_at.to_string(),
finished_at: Some(request.finished_at.to_string()),
root_run_id: Some(execution_id),
execution: Some(RunExecutionRecord {
cwd: std::env::current_dir()
.ok()
.map(|path| path.to_string_lossy().into_owned()),
project_root: Some(request.store_base.to_string_lossy().into_owned()),
source_dir: request
.source_path
.parent()
.map(|path| path.to_string_lossy().into_owned()),
adapter: Some(request.adapter.to_string()),
..RunExecutionRecord::default()
}),
evidence: vm.execution_evidence(flight_recording.cloned(), gaps),
..RunRecord::default()
};
super::save_execution_run_record(&run, &run_path)
.map_err(|error| format!("failed to persist run record: {error}"))?;
Ok(run_path)
}
fn emit_persisted(
vm: &crate::Vm,
run_record_path: &Path,
flight_recording: Option<FlightRecordingArtifact>,
) {
crate::run_events::emit(crate::run_events::RunEvent::EvidencePersisted {
execution_id: vm.execution_id().to_string(),
run_record_path: run_record_path.to_string_lossy().into_owned(),
flight_recording,
});
}
#[cfg(test)]
mod tests {
use super::*;
fn request<'a>(source_path: &'a Path, store_base: &'a Path) -> ExecutionRecordRequest<'a> {
ExecutionRecordRequest {
source_path,
store_base,
adapter: "test",
status: ExecutionRecordStatus::Completed,
started_at: "2026-01-02T00:00:00Z",
finished_at: "2026-01-02T00:00:01Z",
flight_recording: None,
}
}
#[test]
fn interface_persists_a_valid_reloadable_execution_record() {
let dir = tempfile::tempdir().unwrap();
let _locks = crate::conditional_replace::scope_conditional_replace_lock_root(
dir.path().join("locks"),
);
let source = dir.path().join("main.harn");
let mut vm = crate::Vm::new();
vm.enable_flight_recorder(8);
let mut request = request(&source, dir.path());
request.flight_recording = Some(FlightRecordingStorage::Managed { retain_files: 1 });
let persisted = persist_execution_record(&vm, request).unwrap();
let run = super::super::load_run_record(&persisted.run_record_path).unwrap();
assert_eq!(run.id, vm.execution_id().as_str());
assert_eq!(run.status, "completed");
assert_eq!(run.execution.unwrap().adapter.as_deref(), Some("test"));
assert_eq!(
super::super::validate_execution_evidence(&run.evidence),
Ok(())
);
let flight = persisted.flight_recording.unwrap();
assert_eq!(flight.execution_id, run.id);
let flight_path = Path::new(flight.path.as_deref().unwrap());
assert_eq!(
flight_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str()),
Some("flight-recordings")
);
assert!(flight_path.is_file());
}
#[test]
fn flight_failure_persists_one_failed_record_with_an_explicit_gap() {
let dir = tempfile::tempdir().unwrap();
let _locks = crate::conditional_replace::scope_conditional_replace_lock_root(
dir.path().join("locks"),
);
let source = dir.path().join("main.harn");
let output_directory = dir.path().join("not-a-flight-file");
std::fs::create_dir(&output_directory).unwrap();
let mut vm = crate::Vm::new();
vm.enable_flight_recorder(8);
let mut request = request(&source, dir.path());
request.flight_recording = Some(FlightRecordingStorage::Custom {
output: &output_directory,
});
let error = persist_execution_record(&vm, request).unwrap_err();
assert_eq!(error.stage(), ExecutionRecordPersistStage::FlightRecording);
assert_eq!(error.diagnostics().len(), 1);
let run_path =
crate::runtime_paths::run_root(dir.path()).join(format!("{}.json", vm.execution_id()));
let run = super::super::load_run_record(&run_path).unwrap();
assert_eq!(run.status, "failed");
assert_eq!(run.evidence.gaps.len(), 1);
assert_eq!(run.evidence.gaps[0].code, "persist_failed");
assert!(run.evidence.flight_recording.is_none());
}
#[test]
fn run_record_failure_preserves_the_durable_flight_artifact_receipt() {
let dir = tempfile::tempdir().unwrap();
let _locks = crate::conditional_replace::scope_conditional_replace_lock_root(
dir.path().join("locks"),
);
let source = dir.path().join("main.harn");
let run_root = crate::runtime_paths::run_root(dir.path());
std::fs::create_dir_all(run_root.parent().unwrap()).unwrap();
std::fs::write(run_root, "not a directory").unwrap();
let flight_path = dir.path().join("flight.json");
let mut vm = crate::Vm::new();
vm.enable_flight_recorder(8);
let mut request = request(&source, dir.path());
request.flight_recording = Some(FlightRecordingStorage::Custom {
output: &flight_path,
});
let error = persist_execution_record(&vm, request).unwrap_err();
assert_eq!(error.stage(), ExecutionRecordPersistStage::RunRecord);
let flight = error.flight_recording().expect("durable partial artifact");
assert_eq!(flight.execution_id, vm.execution_id().as_str());
assert_eq!(flight.path.as_deref(), flight_path.to_str());
assert!(flight_path.is_file());
}
}